first-update
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions
|
||||
} from '@mui/material';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useBlindTestDetail from '../hooks/useBlindTestDetail';
|
||||
import BlindTestHeader from '../components/BlindTestHeader';
|
||||
import ResultSummary from '../components/ResultSummary';
|
||||
import ResultDetailList from '../components/ResultDetailList';
|
||||
import BlindTestInProgress from '../components/BlindTestInProgress';
|
||||
|
||||
export default function BlindTestDetailPage() {
|
||||
const { projectId, taskId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
task,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
currentQuestion,
|
||||
leftAnswer,
|
||||
rightAnswer,
|
||||
answersLoading,
|
||||
streamingA,
|
||||
streamingB,
|
||||
voting,
|
||||
completed,
|
||||
fetchCurrentQuestion,
|
||||
submitVote,
|
||||
interruptTask,
|
||||
getResultStats
|
||||
} = useBlindTestDetail(projectId, taskId);
|
||||
|
||||
const [interruptDialog, setInterruptDialog] = useState(false);
|
||||
|
||||
const handleBack = () => router.push(`/projects/${projectId}/blind-test-tasks`);
|
||||
|
||||
const handleVote = async vote => {
|
||||
await submitVote(vote);
|
||||
};
|
||||
|
||||
const handleInterrupt = async () => {
|
||||
await interruptTask();
|
||||
setInterruptDialog(false);
|
||||
};
|
||||
|
||||
// 加载中
|
||||
if (loading) {
|
||||
return (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{ py: 3, height: 'calc(100vh - 64px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 任务不存在
|
||||
if (!task) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<BlindTestHeader title={t('blindTest.taskNotFound', '任务不存在')} onBack={handleBack} />
|
||||
<Alert severity="error">{t('blindTest.taskNotFound', '任务不存在')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const isResultView = completed || task.status !== 0;
|
||||
const stats = getResultStats();
|
||||
|
||||
// 结果展示页面(已完成或已中断)
|
||||
if (isResultView) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3, height: 'calc(100vh - 64px)', overflow: 'auto' }}>
|
||||
<BlindTestHeader title={t('blindTest.resultTitle', '盲测结果')} status={task.status} onBack={handleBack} />
|
||||
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||
<ResultSummary stats={stats} modelInfo={task.modelInfo || '{}'} />
|
||||
<ResultDetailList task={task} />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 盲测进行中页面
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3, height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<BlindTestHeader
|
||||
title={t('blindTest.inProgress', '盲测进行中')}
|
||||
onBack={handleBack}
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={() => setInterruptDialog(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('blindTest.interrupt', '中断任务')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<BlindTestInProgress
|
||||
task={task}
|
||||
currentQuestion={currentQuestion}
|
||||
leftAnswer={leftAnswer}
|
||||
rightAnswer={rightAnswer}
|
||||
streamingA={streamingA}
|
||||
streamingB={streamingB}
|
||||
answersLoading={answersLoading}
|
||||
voting={voting}
|
||||
onVote={handleVote}
|
||||
onReload={fetchCurrentQuestion}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 中断确认对话框 */}
|
||||
<Dialog open={interruptDialog} onClose={() => setInterruptDialog(false)}>
|
||||
<DialogTitle>{t('blindTest.interruptConfirmTitle', '确认中断')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('blindTest.interruptConfirmMessage', '确定要中断这个盲测任务吗?已完成的评判结果将保留。')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setInterruptDialog(false)}>{t('common.cancel', '取消')}</Button>
|
||||
<Button color="warning" variant="contained" onClick={handleInterrupt}>
|
||||
{t('blindTest.interrupt', '中断')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Box, Typography, IconButton, Chip, Button } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { blindTestStyles } from '@/styles/blindTest';
|
||||
|
||||
export default function BlindTestHeader({ title, status, onBack, actions }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const styles = blindTestStyles(theme);
|
||||
|
||||
const getStatusConfig = s => {
|
||||
switch (s) {
|
||||
case 1:
|
||||
return { label: 'blindTest.statusCompleted', color: 'success' };
|
||||
case 3:
|
||||
return { label: 'blindTest.statusInterrupted', color: 'warning' };
|
||||
default:
|
||||
return { label: 'blindTest.statusProcessing', color: 'primary' };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = status !== undefined ? getStatusConfig(status) : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...styles.header,
|
||||
bgcolor: 'background.paper',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
px: 3,
|
||||
py: 2,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={onBack} size="small" sx={{ bgcolor: 'action.hover' }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
||||
p: 1,
|
||||
borderRadius: 1.5
|
||||
}}
|
||||
>
|
||||
<CompareArrowsIcon sx={{ fontSize: 24, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{statusConfig && (
|
||||
<Chip
|
||||
label={t(statusConfig.label)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha(theme.palette[statusConfig.color].main, 0.1),
|
||||
color: `${statusConfig.color}.main`,
|
||||
fontWeight: 600,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette[statusConfig.color].main, 0.2),
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>{actions}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Fade,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
|
||||
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
|
||||
import ThumbsUpDownIcon from '@mui/icons-material/ThumbsUpDown';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
import { blindTestStyles } from '@/styles/blindTest';
|
||||
|
||||
function AnswerBox({ title, modelLabel, answer, streaming, showThinking, setShowThinking, scrollRef, styles, theme }) {
|
||||
const { t } = useTranslation();
|
||||
const isLeft = modelLabel === 'A';
|
||||
const avatarColor = isLeft ? 'primary.main' : 'secondary.main';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...styles.answerPaper,
|
||||
border: '1px solid',
|
||||
borderColor: streaming ? (isLeft ? 'primary.main' : 'secondary.main') : 'divider',
|
||||
boxShadow: streaming ? `0 0 0 2px ${alpha(theme.palette[isLeft ? 'primary' : 'secondary'].main, 0.1)}` : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
...styles.answerHeader,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: avatarColor,
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
{modelLabel}
|
||||
</Avatar>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{streaming && <CircularProgress size={14} color={isLeft ? 'primary' : 'secondary'} />}
|
||||
</Box>
|
||||
{answer?.duration > 0 && !streaming && (
|
||||
<Chip
|
||||
label={`${(answer.duration / 1000).toFixed(1)}s`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.75rem', bgcolor: 'background.paper' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{answer?.error ? (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error" variant="outlined">
|
||||
{answer.error}
|
||||
</Alert>
|
||||
</Box>
|
||||
) : (
|
||||
<Box ref={scrollRef} sx={{ ...styles.answerContent, flex: 1 }}>
|
||||
{/* 思维链渲染 */}
|
||||
{answer?.thinking && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.04),
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': { bgcolor: alpha(theme.palette.text.primary, 0.08) },
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{answer.isThinking ? (
|
||||
<AutoFixHighIcon
|
||||
fontSize="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
animation: 'thinking-pulse 1.5s infinite',
|
||||
'@keyframes thinking-pulse': {
|
||||
'0%': { opacity: 0.4 },
|
||||
'50%': { opacity: 1 },
|
||||
'100%': { opacity: 0.4 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PsychologyIcon fontSize="small" color="action" />
|
||||
)}
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
{t('playground.reasoningProcess', '推理过程')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||
{showThinking ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Collapse in={showThinking}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'grey.50',
|
||||
borderRadius: 2,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
mb: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: 'text.secondary' }}>
|
||||
{answer.thinking}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{answer?.content ? (
|
||||
<div className="markdown-body" style={{ fontSize: '0.95rem', backgroundColor: 'transparent' }}>
|
||||
<ReactMarkdown>{answer.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
color: 'text.secondary',
|
||||
mt: 4,
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<SmartToyIcon sx={{ opacity: 0.5 }} />
|
||||
<Typography variant="body2">{t('blindTest.generatingAnswers', '正在生成回答...')}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlindTestInProgress({
|
||||
task,
|
||||
currentQuestion,
|
||||
leftAnswer,
|
||||
rightAnswer,
|
||||
streamingA,
|
||||
streamingB,
|
||||
answersLoading,
|
||||
voting,
|
||||
onVote,
|
||||
onReload
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const styles = blindTestStyles(theme);
|
||||
|
||||
const [showThinkingLeft, setShowThinkingLeft] = useState(true);
|
||||
const [showThinkingRight, setShowThinkingRight] = useState(true);
|
||||
|
||||
// 自动滚动引用
|
||||
const leftScrollRef = useRef(null);
|
||||
const rightScrollRef = useRef(null);
|
||||
|
||||
// 处理自动滚动
|
||||
useEffect(() => {
|
||||
if (streamingA && leftScrollRef.current) {
|
||||
leftScrollRef.current.scrollTop = leftScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [leftAnswer?.content, leftAnswer?.thinking, streamingA]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingB && rightScrollRef.current) {
|
||||
rightScrollRef.current.scrollTop = rightScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [rightAnswer?.content, rightAnswer?.thinking, streamingB]);
|
||||
|
||||
const progress = task ? (task.completedCount / task.totalCount) * 100 : 0;
|
||||
|
||||
if (answersLoading && !currentQuestion) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} thickness={4} />
|
||||
<Typography color="text.secondary" variant="h6" sx={{ fontWeight: 500 }}>
|
||||
{t('blindTest.generatingAnswers', '正在准备题目...')}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentQuestion) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onReload}
|
||||
sx={{ borderRadius: 3, px: 4, py: 1.5, boxShadow: 4 }}
|
||||
>
|
||||
{t('blindTest.loadQuestion', '加载题目')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, height: '100%' }}>
|
||||
{/* 顶部进度和问题 */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...styles.questionPaper,
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<Box sx={{ bgcolor: 'background.default', px: 3, py: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ minWidth: 80, fontWeight: 600 }}>
|
||||
{t('blindTest.progress', '进度')} {task.completedCount + 1}/{task.totalCount}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
bgcolor: 'action.hover',
|
||||
'& .MuiLinearProgress-bar': { borderRadius: 4 }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.5 }}>
|
||||
{currentQuestion.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* 回答区域 */}
|
||||
<Box sx={{ ...styles.answersContainer, gap: 3 }}>
|
||||
<AnswerBox
|
||||
title={t('blindTest.answerA', '回答 A')}
|
||||
modelLabel="A"
|
||||
answer={leftAnswer}
|
||||
streaming={streamingA}
|
||||
showThinking={showThinkingLeft}
|
||||
setShowThinking={setShowThinkingLeft}
|
||||
scrollRef={leftScrollRef}
|
||||
styles={styles}
|
||||
theme={theme}
|
||||
/>
|
||||
<AnswerBox
|
||||
title={t('blindTest.answerB', '回答 B')}
|
||||
modelLabel="B"
|
||||
answer={rightAnswer}
|
||||
streaming={streamingB}
|
||||
showThinking={showThinkingRight}
|
||||
setShowThinking={setShowThinkingRight}
|
||||
scrollRef={rightScrollRef}
|
||||
styles={styles}
|
||||
theme={theme}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 底部投票区域 */}
|
||||
<Paper
|
||||
elevation={10}
|
||||
sx={{
|
||||
...styles.voteBar,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha(theme.palette.background.paper, 0.9),
|
||||
backdropFilter: 'blur(20px)',
|
||||
width: 'fit-content',
|
||||
minWidth: 800,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
<Box sx={styles.voteButtons}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<ThumbUpIcon />}
|
||||
onClick={() => onVote('left')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, bgcolor: 'primary.main', '&:hover': { bgcolor: 'primary.dark' } }}
|
||||
>
|
||||
{t('blindTest.leftBetter', '左边更好')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="large"
|
||||
startIcon={<ThumbsUpDownIcon />}
|
||||
onClick={() => onVote('both_good')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, borderWidth: 2, '&:hover': { borderWidth: 2 } }}
|
||||
>
|
||||
{t('blindTest.bothGood', '都好')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
title={
|
||||
currentQuestion?.answer ? (
|
||||
<Box sx={{ p: 1, maxWidth: 400 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 0.5, fontWeight: 'bold' }}
|
||||
>
|
||||
<AssignmentIcon fontSize="small" color="primary" />
|
||||
{t('blindTest.referenceAnswer', '参考答案')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: 'pre-wrap', maxHeight: 300, overflowY: 'auto', color: 'text.secondary' }}
|
||||
>
|
||||
{currentQuestion.answer}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
t('blindTest.noReferenceAnswer', '暂无参考答案')
|
||||
)
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
TransitionComponent={Fade}
|
||||
TransitionProps={{ timeout: 600 }}
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'grey.900' : 'background.paper',
|
||||
color: 'text.primary',
|
||||
boxShadow: theme.shadows[8],
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
p: 0,
|
||||
'& .MuiTooltip-arrow': {
|
||||
color: theme.palette.mode === 'dark' ? 'grey.900' : 'background.paper',
|
||||
'&::before': {
|
||||
border: `1px solid ${theme.palette.divider}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="large"
|
||||
startIcon={<InfoOutlinedIcon />}
|
||||
sx={{
|
||||
...styles.voteBtn,
|
||||
color: 'text.secondary',
|
||||
minWidth: 'auto',
|
||||
px: 2
|
||||
}}
|
||||
>
|
||||
{t('blindTest.referenceAnswer', '参考答案')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<ThumbDownIcon />}
|
||||
onClick={() => onVote('both_bad')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, borderWidth: 2, '&:hover': { borderWidth: 2 } }}
|
||||
>
|
||||
{t('blindTest.bothBad', '都不好')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
size="large"
|
||||
startIcon={<ThumbUpIcon />}
|
||||
onClick={() => onVote('right')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, bgcolor: 'secondary.main', '&:hover': { bgcolor: 'secondary.dark' } }}
|
||||
>
|
||||
{t('blindTest.rightBetter', '右边更好')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
LinearProgress,
|
||||
Avatar,
|
||||
Grid,
|
||||
Tooltip,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
|
||||
const STATUS_MAP = {
|
||||
0: { label: 'blindTest.statusProcessing', color: 'primary', bgColor: 'primary.main' },
|
||||
1: { label: 'blindTest.statusCompleted', color: 'success', bgColor: 'success.main' },
|
||||
2: { label: 'blindTest.statusFailed', color: 'error', bgColor: 'error.main' },
|
||||
3: { label: 'blindTest.statusInterrupted', color: 'warning', bgColor: 'warning.main' }
|
||||
};
|
||||
|
||||
export default function BlindTestTaskCard({ task, onView, onDelete, onInterrupt, onContinue }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
const handleMenuOpen = e => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleView = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onView?.(task);
|
||||
};
|
||||
|
||||
const handleDelete = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onDelete?.(task);
|
||||
};
|
||||
|
||||
const handleInterrupt = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onInterrupt?.(task);
|
||||
};
|
||||
|
||||
const handleContinue = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onContinue?.(task);
|
||||
};
|
||||
|
||||
const statusConfig = STATUS_MAP[task.status] || STATUS_MAP[0];
|
||||
const progress = task.totalCount > 0 ? (task.completedCount / task.totalCount) * 100 : 0;
|
||||
const isProcessing = task.status === 0;
|
||||
const isCompleted = task.status === 1;
|
||||
|
||||
// 计算模型得分
|
||||
const results = task.detail?.results || [];
|
||||
const modelAScore = results.reduce((sum, r) => sum + (r.modelAScore || 0), 0);
|
||||
const modelBScore = results.reduce((sum, r) => sum + (r.modelBScore || 0), 0);
|
||||
const totalScore = modelAScore + modelBScore;
|
||||
|
||||
// Calculate win percentages for visual bar
|
||||
const modelAPercent = totalScore > 0 ? (modelAScore / totalScore) * 100 : 50;
|
||||
const modelBPercent = totalScore > 0 ? (modelBScore / totalScore) * 100 : 50;
|
||||
|
||||
const winner = isCompleted ? (modelAScore > modelBScore ? 'A' : modelBScore > modelAScore ? 'B' : 'Tie') : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3,
|
||||
overflow: 'visible',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={e => handleView(e)}
|
||||
>
|
||||
<CardContent sx={{ p: '20px !important' }}>
|
||||
<Grid container alignItems="center" spacing={3}>
|
||||
{/* Status & Time */}
|
||||
<Grid item xs={12} md={2} lg={1.5}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Chip
|
||||
label={t(statusConfig.label)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha(theme.palette[statusConfig.color].main, 0.1),
|
||||
color: `${statusConfig.color}.main`,
|
||||
fontWeight: 600,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette[statusConfig.color].main, 0.2),
|
||||
width: 'fit-content'
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'text.secondary' }}>
|
||||
<AccessTimeIcon sx={{ fontSize: 14 }} />
|
||||
<Typography variant="caption" noWrap>
|
||||
{new Date(task.createAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Model Comparison Area */}
|
||||
<Grid item xs={12} md={9} lg={9.5}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
{/* Model A */}
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, minWidth: 0 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '1rem',
|
||||
boxShadow:
|
||||
winner === 'A'
|
||||
? `0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette.primary.main}`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
A
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={task.modelInfo?.modelA?.modelName}>
|
||||
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 600 }}>
|
||||
{task.modelInfo?.modelA?.modelName || 'Model A'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Typography variant="caption" color="text.secondary" noWrap display="block">
|
||||
{task.modelInfo?.modelA?.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isCompleted && winner === 'A' && <EmojiEventsIcon color="primary" />}
|
||||
</Box>
|
||||
|
||||
{/* Center Status/Score */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 140,
|
||||
textAlign: 'center',
|
||||
px: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, letterSpacing: 1, lineHeight: 1 }}>
|
||||
<span style={{ color: theme.palette.primary.main }}>{modelAScore.toFixed(1)}</span>
|
||||
<span style={{ color: theme.palette.text.disabled, margin: '0 4px', fontSize: '0.8em' }}>:</span>
|
||||
<span style={{ color: theme.palette.secondary.main }}>{modelBScore.toFixed(1)}</span>
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
bgcolor: 'grey.100'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: `${modelAPercent}%`, bgcolor: 'primary.main' }} />
|
||||
<Box sx={{ width: `${modelBPercent}%`, bgcolor: 'secondary.main' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
fontWeight="bold"
|
||||
sx={{ mb: 0.5, display: 'block' }}
|
||||
>
|
||||
VS
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{ height: 6, borderRadius: 3, bgcolor: 'action.hover' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5, display: 'block', fontSize: '0.7rem' }}
|
||||
>
|
||||
{Math.round(progress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Model B */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
flexDirection: 'row-reverse'
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
bgcolor: 'secondary.main',
|
||||
fontSize: '1rem',
|
||||
boxShadow:
|
||||
winner === 'B'
|
||||
? `0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette.secondary.main}`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
B
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0, textAlign: 'right' }}>
|
||||
<Tooltip title={task.modelInfo?.modelB?.modelName}>
|
||||
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 600 }}>
|
||||
{task.modelInfo?.modelB?.modelName || 'Model B'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Typography variant="caption" color="text.secondary" noWrap display="block">
|
||||
{task.modelInfo?.modelB?.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isCompleted && winner === 'B' && <EmojiEventsIcon color="secondary" />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Menu */}
|
||||
<Grid item xs={12} md={1} lg={1} sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<IconButton size="small" onClick={handleMenuOpen} sx={{ color: 'text.secondary' }}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
|
||||
{/* 菜单 */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
PaperProps={{
|
||||
elevation: 2,
|
||||
sx: {
|
||||
mt: 1,
|
||||
minWidth: 160,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleView} sx={{ gap: 1.5, py: 1 }}>
|
||||
<VisibilityIcon fontSize="small" color="action" />
|
||||
<Typography variant="body2">{t('blindTest.viewDetails', '查看详情')}</Typography>
|
||||
</MenuItem>
|
||||
{isProcessing && (
|
||||
<MenuItem onClick={handleContinue} sx={{ gap: 1.5, py: 1 }}>
|
||||
<PlayArrowIcon fontSize="small" color="primary" />
|
||||
<Typography variant="body2">{t('blindTest.continue', '继续盲测')}</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<MenuItem onClick={handleInterrupt} sx={{ gap: 1.5, py: 1 }}>
|
||||
<StopIcon fontSize="small" color="warning" />
|
||||
<Typography variant="body2">{t('blindTest.interrupt', '中断任务')}</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<MenuItem onClick={handleDelete} sx={{ gap: 1.5, py: 1, color: 'error.main' }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
<Typography variant="body2">{t('common.delete', '删除')}</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Divider,
|
||||
TextField,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
|
||||
export default function CreateBlindTestDialog({ open, onClose, projectId, onCreate }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
// 模型选择
|
||||
const [models, setModels] = useState([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelA, setModelA] = useState(null);
|
||||
const [modelB, setModelB] = useState(null);
|
||||
|
||||
// 题目选择
|
||||
const [questionTypes, setQuestionTypes] = useState(['short_answer', 'open_ended']);
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [questionCount, setQuestionCount] = useState(0);
|
||||
const [filteredCount, setFilteredCount] = useState(0);
|
||||
const [countLoading, setCountLoading] = useState(false);
|
||||
|
||||
// 提交状态
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 加载模型列表
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return;
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
setModelsLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/model-config`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data) {
|
||||
setModels(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载模型失败:', err);
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels();
|
||||
}, [open, projectId]);
|
||||
|
||||
// 加载标签和题目数量
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return;
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets?page=1&pageSize=1&includeStats=true`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.stats?.byTag) {
|
||||
setAvailableTags(Object.keys(result.stats.byTag).sort());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载统计失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [open, projectId]);
|
||||
|
||||
// 获取符合条件的题目数量
|
||||
const fetchFilteredCount = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setCountLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// 只查询主观题
|
||||
questionTypes.forEach(t => params.append('questionTypes', t));
|
||||
selectedTags.forEach(t => params.append('tags', t));
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/count?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setFilteredCount(result.data?.total || 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取题目数量失败:', err);
|
||||
} finally {
|
||||
setCountLoading(false);
|
||||
}
|
||||
}, [projectId, questionTypes, selectedTags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchFilteredCount();
|
||||
}
|
||||
}, [open, fetchFilteredCount]);
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setModelA(null);
|
||||
setModelB(null);
|
||||
setQuestionTypes(['short_answer', 'open_ended']);
|
||||
setSelectedTags([]);
|
||||
setQuestionCount(0);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
if (submitting) return;
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 提交创建
|
||||
const handleSubmit = async () => {
|
||||
// 验证
|
||||
if (!modelA) {
|
||||
setError(t('blindTest.errorSelectModelA', '请选择模型A'));
|
||||
return;
|
||||
}
|
||||
if (!modelB) {
|
||||
setError(t('blindTest.errorSelectModelB', '请选择模型B'));
|
||||
return;
|
||||
}
|
||||
if (modelA.id === modelB.id) {
|
||||
setError(t('blindTest.errorSameModel', '两个模型不能相同'));
|
||||
return;
|
||||
}
|
||||
if (filteredCount === 0) {
|
||||
setError(t('blindTest.errorNoQuestions', '没有符合条件的题目'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
// 获取题目ID列表
|
||||
const params = new URLSearchParams();
|
||||
questionTypes.forEach(t => params.append('questionTypes', t));
|
||||
selectedTags.forEach(t => params.append('tags', t));
|
||||
|
||||
const pageSize = questionCount > 0 ? questionCount : filteredCount;
|
||||
params.append('pageSize', pageSize.toString());
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.items || result.items.length === 0) {
|
||||
setError(t('blindTest.errorNoQuestions', '没有符合条件的题目'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机选择题目(如果指定了数量)
|
||||
let selectedIds = result.items.map(item => item.id);
|
||||
if (questionCount > 0 && questionCount < selectedIds.length) {
|
||||
// 随机抽取
|
||||
selectedIds = selectedIds.sort(() => Math.random() - 0.5).slice(0, questionCount);
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
const createResult = await onCreate({
|
||||
modelA: { modelId: modelA.modelId, providerId: modelA.providerId, id: modelA.id },
|
||||
modelB: { modelId: modelB.modelId, providerId: modelB.providerId, id: modelB.id },
|
||||
evalDatasetIds: selectedIds
|
||||
});
|
||||
|
||||
if (createResult.success) {
|
||||
handleClose();
|
||||
} else {
|
||||
setError(createResult.error || '创建失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建任务失败:', err);
|
||||
setError('创建任务失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' },
|
||||
{ value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
backgroundImage: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
pb: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1, borderRadius: 1.5, bgcolor: alpha(theme.palette.primary.main, 0.1), display: 'flex' }}>
|
||||
<CompareArrowsIcon color="primary" />
|
||||
</Box>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('blindTest.createTitle', '创建盲测任务')}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ py: 3 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 模型选择 */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<SmartToyIcon fontSize="small" color="action" />
|
||||
{t('blindTest.selectModels', '选择对比模型')}
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3, bgcolor: 'background.default' }}>
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* 模型A */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Avatar sx={{ width: 24, height: 24, bgcolor: 'primary.main', fontSize: '0.75rem' }}>A</Avatar>
|
||||
<Typography variant="subtitle2" color="primary.main" fontWeight="bold">
|
||||
{t('blindTest.modelA', '模型 A')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={modelA?.id || ''}
|
||||
onChange={e => {
|
||||
const selected = models.find(m => m.id === e.target.value);
|
||||
setModelA(selected || null);
|
||||
}}
|
||||
displayEmpty
|
||||
disabled={modelsLoading}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
<Typography color="text.secondary">{t('common.select', '请选择')}</Typography>
|
||||
</MenuItem>
|
||||
{models.map(model => (
|
||||
<MenuItem key={model.id} value={model.id} disabled={model.id === modelB?.id}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{model.modelName || model.modelId}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ alignSelf: 'center', pt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'action.hover',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
VS
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 模型B */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Avatar sx={{ width: 24, height: 24, bgcolor: 'secondary.main', fontSize: '0.75rem' }}>B</Avatar>
|
||||
<Typography variant="subtitle2" color="secondary.main" fontWeight="bold">
|
||||
{t('blindTest.modelB', '模型 B')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={modelB?.id || ''}
|
||||
onChange={e => {
|
||||
const selected = models.find(m => m.id === e.target.value);
|
||||
setModelB(selected || null);
|
||||
}}
|
||||
displayEmpty
|
||||
disabled={modelsLoading}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
<Typography color="text.secondary">{t('common.select', '请选择')}</Typography>
|
||||
</MenuItem>
|
||||
{models.map(model => (
|
||||
<MenuItem key={model.id} value={model.id} disabled={model.id === modelA?.id}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{model.modelName || model.modelId}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{models.length === 0 && !modelsLoading && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
{t('blindTest.noModelsAvailable', '暂无可用模型,请先在设置中配置模型')}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* 题目筛选 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
{t('blindTest.selectQuestions', '选择测试题目')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('blindTest.questionTypeHint', '盲测任务仅支持简答题和开放题')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 题型筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('blindTest.questionType', '题型')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={questionTypes}
|
||||
onChange={e => setQuestionTypes(e.target.value)}
|
||||
input={<OutlinedInput label={t('blindTest.questionType', '题型')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={t(`eval.questionTypes.${value}`)} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
<Checkbox checked={questionTypes.includes(type.value)} />
|
||||
<ListItemText primary={t(type.labelKey)} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
{availableTags.length > 0 && (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('blindTest.filterByTag', '按标签筛选')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={e => setSelectedTags(e.target.value)}
|
||||
input={<OutlinedInput label={t('blindTest.filterByTag', '按标签筛选')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{availableTags.map(tag => (
|
||||
<MenuItem key={tag} value={tag}>
|
||||
<Checkbox checked={selectedTags.includes(tag)} />
|
||||
<ListItemText primary={tag} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 题目数量 */}
|
||||
<Box sx={{ p: 2, bgcolor: 'background.default', borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label={t('blindTest.questionCount', '题目数量')}
|
||||
value={questionCount}
|
||||
onChange={e => setQuestionCount(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
inputProps={{ min: 0, max: filteredCount }}
|
||||
sx={{ width: 150, bgcolor: 'background.paper' }}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{countLoading ? (
|
||||
<CircularProgress size={14} />
|
||||
) : (
|
||||
t('blindTest.availableQuestions', '可用题目:{{count}} 道', { count: filteredCount })
|
||||
)}
|
||||
</Typography>
|
||||
{filteredCount > 0 && (
|
||||
<Chip
|
||||
label={t('common.ready', '就绪')}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{questionCount === 0
|
||||
? t('blindTest.useAllQuestions', '使用全部筛选结果')
|
||||
: t('blindTest.randomSample', '将随机抽取 {{count}} 道题目', { count: questionCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 3, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Button onClick={handleClose} disabled={submitting} color="inherit" size="large">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={submitting || !modelA || !modelB || filteredCount === 0}
|
||||
startIcon={submitting ? <CircularProgress size={16} color="inherit" /> : <CompareArrowsIcon />}
|
||||
size="large"
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{submitting ? t('blindTest.creating', '创建中...') : t('blindTest.startBlindTest', '开始盲测')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { Box, Paper, Typography, Chip, Collapse, IconButton, Avatar, Divider, Grid } from '@mui/material';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
|
||||
import HelpIcon from '@mui/icons-material/Help';
|
||||
import { useState } from 'react';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
// 解析包含 <think> 标签的内容
|
||||
const parseAnswerContent = text => {
|
||||
if (!text) return { thinking: '', content: '' };
|
||||
|
||||
// 匹配 <think>...</think> 内容
|
||||
const thinkMatch = text.match(/<think>([\s\S]*?)<\/think>/);
|
||||
|
||||
if (thinkMatch) {
|
||||
return {
|
||||
thinking: thinkMatch[1].trim(),
|
||||
content: text.replace(/<think>[\s\S]*?<\/think>/, '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
return { thinking: '', content: text };
|
||||
};
|
||||
|
||||
function ResultAnswerSection({ title, rawContent, isWinner, modelLabel, t, theme }) {
|
||||
const { thinking, content } = parseAnswerContent(rawContent);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
|
||||
const isLeft = modelLabel.includes('A') || title.includes('左');
|
||||
const avatarColor = isLeft ? 'primary.main' : 'secondary.main';
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
bgcolor: avatarColor,
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{modelLabel}
|
||||
</Avatar>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isWinner && (
|
||||
<Chip
|
||||
icon={<EmojiEventsIcon sx={{ fontSize: '1rem !important' }} />}
|
||||
label={t('blindTest.winner', '胜出')}
|
||||
size="small"
|
||||
color={isLeft ? 'primary' : 'secondary'}
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 3,
|
||||
flex: 1,
|
||||
bgcolor: isWinner
|
||||
? alpha(isLeft ? theme.palette.primary.main : theme.palette.secondary.main, 0.02)
|
||||
: 'background.paper',
|
||||
borderColor: isWinner
|
||||
? alpha(isLeft ? theme.palette.primary.main : theme.palette.secondary.main, 0.3)
|
||||
: 'divider',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 思维链展示 */}
|
||||
{thinking && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
mb: 1.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.05),
|
||||
'&:hover': { bgcolor: alpha(theme.palette.text.primary, 0.08) },
|
||||
width: 'fit-content'
|
||||
}}
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
>
|
||||
<PsychologyIcon fontSize="small" color="action" />
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
{t('playground.reasoningProcess', '推理过程')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 1 }}>
|
||||
{showThinking ? (
|
||||
<ExpandLessIcon fontSize="small" color="action" />
|
||||
) : (
|
||||
<ExpandMoreIcon fontSize="small" color="action" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Collapse in={showThinking}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.3)' : 'grey.50',
|
||||
borderRadius: 2,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
mb: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: 'text.secondary' }}>
|
||||
{thinking}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 正文内容 */}
|
||||
<div className="markdown-body" style={{ fontSize: '0.95rem', backgroundColor: 'transparent' }}>
|
||||
<ReactMarkdown>{content || '-'}</ReactMarkdown>
|
||||
</div>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultItem({ result, index, task, question }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Determine vote icon and color
|
||||
let VoteIcon = HelpIcon;
|
||||
let voteColor = 'default';
|
||||
let voteLabel = '';
|
||||
|
||||
switch (result.vote) {
|
||||
case 'left':
|
||||
VoteIcon = CheckCircleIcon;
|
||||
voteColor = 'primary';
|
||||
voteLabel = t('blindTest.leftBetter', '左边更好');
|
||||
break;
|
||||
case 'right':
|
||||
VoteIcon = CheckCircleIcon;
|
||||
voteColor = 'secondary';
|
||||
voteLabel = t('blindTest.rightBetter', '右边更好');
|
||||
break;
|
||||
case 'both_good':
|
||||
VoteIcon = CheckCircleIcon;
|
||||
voteColor = 'success';
|
||||
voteLabel = t('blindTest.bothGood', '都好');
|
||||
break;
|
||||
case 'both_bad':
|
||||
VoteIcon = CancelIcon;
|
||||
voteColor = 'error';
|
||||
voteLabel = t('blindTest.bothBad', '都不好');
|
||||
break;
|
||||
default:
|
||||
VoteIcon = RemoveCircleIcon;
|
||||
voteLabel = t('blindTest.ties', '平局');
|
||||
}
|
||||
|
||||
// Determine Model labels based on swap status
|
||||
const leftModelName = result.isSwapped ? task.modelInfo?.modelB?.modelName : task.modelInfo?.modelA?.modelName;
|
||||
const rightModelName = result.isSwapped ? task.modelInfo?.modelA?.modelName : task.modelInfo?.modelB?.modelName;
|
||||
const leftModelLabel = result.isSwapped ? 'B' : 'A';
|
||||
const rightModelLabel = result.isSwapped ? 'A' : 'B';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mb: 2,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: expanded ? 'primary.main' : 'divider',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.08)}`
|
||||
}
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
{/* 头部摘要 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
bgcolor: expanded ? alpha(theme.palette.primary.main, 0.02) : 'transparent'
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, overflow: 'hidden', flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'action.hover',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
#{index + 1}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
flex: 1,
|
||||
maxWidth: { xs: 200, md: 800 }
|
||||
}}
|
||||
>
|
||||
{question?.question || result.questionId}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<VoteIcon fontSize="small" />}
|
||||
label={voteLabel}
|
||||
color={voteColor === 'default' ? 'default' : voteColor}
|
||||
variant={result.vote === 'both_good' || result.vote === 'both_bad' ? 'outlined' : 'filled'}
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s'
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 展开详情 */}
|
||||
<Collapse in={expanded}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 4, bgcolor: 'background.default' }}>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
p: 3,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
QUESTION
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.6 }}>
|
||||
{question?.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* 左侧详情 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<ResultAnswerSection
|
||||
title={`${leftModelName}`}
|
||||
modelLabel={leftModelLabel}
|
||||
rawContent={result.leftAnswer}
|
||||
isWinner={result.vote === 'left'}
|
||||
t={t}
|
||||
theme={theme}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 右侧详情 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<ResultAnswerSection
|
||||
title={`${rightModelName}`}
|
||||
modelLabel={rightModelLabel}
|
||||
rawContent={result.rightAnswer}
|
||||
isWinner={result.vote === 'right'}
|
||||
t={t}
|
||||
theme={theme}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResultDetailList({ task }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, fontWeight: 700 }}>
|
||||
{t('blindTest.detailResults', '详细结果')}
|
||||
</Typography>
|
||||
|
||||
{task.detail?.results?.map((result, index) => {
|
||||
const question = task.evalDatasets?.find(q => q.id === result.questionId);
|
||||
return <ResultItem key={index} result={result} index={index} task={task} question={question} />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Box, Paper, Typography, Card, CardContent, Chip, Grid, Avatar } from '@mui/material';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { blindTestStyles } from '@/styles/blindTest';
|
||||
|
||||
export default function ResultSummary({ stats, modelInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const totalScore = stats.modelAScore + stats.modelBScore;
|
||||
const modelAPercent = totalScore > 0 ? (stats.modelAScore / totalScore) * 100 : 50;
|
||||
const modelBPercent = totalScore > 0 ? (stats.modelBScore / totalScore) * 100 : 50;
|
||||
|
||||
const winner = stats.modelAScore > stats.modelBScore ? 'A' : stats.modelBScore > stats.modelAScore ? 'B' : 'tie';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, 1)} 0%, ${alpha(theme.palette.background.default, 0.5)} 100%)`
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 4, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<EmojiEventsIcon color="warning" />
|
||||
{t('blindTest.resultSummary', '评测结果汇总')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4} alignItems="center">
|
||||
{/* Model A */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: winner === 'A' ? 'primary.main' : 'divider',
|
||||
bgcolor: winner === 'A' ? alpha(theme.palette.primary.main, 0.05) : 'background.paper',
|
||||
position: 'relative',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
{winner === 'A' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 10,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: 2
|
||||
}}
|
||||
>
|
||||
WINNER
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
bgcolor: 'primary.main',
|
||||
mb: 2,
|
||||
mx: 'auto',
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
A
|
||||
</Avatar>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }} noWrap>
|
||||
{modelInfo?.modelA?.modelName || 'Model A'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={modelInfo?.modelA?.providerName}
|
||||
size="small"
|
||||
sx={{ mb: 2, bgcolor: 'background.default' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h3" color="primary.main" sx={{ fontWeight: 800 }}>
|
||||
{stats.modelAScore.toFixed(1)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('blindTest.wins', '胜出')}: {stats.modelAWins}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* VS / Progress */}
|
||||
<Grid item xs={12} md={2}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="text.disabled" sx={{ fontWeight: 900, opacity: 0.2, mb: 2 }}>
|
||||
VS
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${modelAPercent}%`,
|
||||
bgcolor: 'primary.main',
|
||||
transition: 'width 1s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${modelBPercent}%`,
|
||||
bgcolor: 'secondary.main',
|
||||
transition: 'width 1s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Model B */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: winner === 'B' ? 'secondary.main' : 'divider',
|
||||
bgcolor: winner === 'B' ? alpha(theme.palette.secondary.main, 0.05) : 'background.paper',
|
||||
position: 'relative',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
{winner === 'B' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bgcolor: 'secondary.main',
|
||||
color: 'white',
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 10,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: 2
|
||||
}}
|
||||
>
|
||||
WINNER
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
bgcolor: 'secondary.main',
|
||||
mb: 2,
|
||||
mx: 'auto',
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
B
|
||||
</Avatar>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }} noWrap>
|
||||
{modelInfo?.modelB?.modelName || 'Model B'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={modelInfo?.modelB?.providerName}
|
||||
size="small"
|
||||
sx={{ mb: 2, bgcolor: 'background.default' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h3" color="secondary.main" sx={{ fontWeight: 800 }}>
|
||||
{stats.modelBScore.toFixed(1)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('blindTest.wins', '胜出')}: {stats.modelBWins}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* 底部统计条 */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2} justifyContent="center" alignItems="center">
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.totalQuestions}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.totalQuestions', '总题数')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="success.main" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.bothGood}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.bothGood', '都好')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="error.main" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.bothBad}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.bothBad', '都不好')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="text.secondary" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.ties}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.ties', '平局')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 盲测任务详情和盲测过程管理 Hook
|
||||
*/
|
||||
export default function useBlindTestDetail(projectId, taskId) {
|
||||
// 任务详情
|
||||
const [task, setTask] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 当前题目状态
|
||||
const [currentQuestion, setCurrentQuestion] = useState(null);
|
||||
const [leftAnswer, setLeftAnswer] = useState(null);
|
||||
const [rightAnswer, setRightAnswer] = useState(null);
|
||||
const [isSwapped, setIsSwapped] = useState(false);
|
||||
const [answersLoading, setAnswersLoading] = useState(false);
|
||||
|
||||
// 流式输出状态
|
||||
const [streamingA, setStreamingA] = useState(false);
|
||||
const [streamingB, setStreamingB] = useState(false);
|
||||
const abortControllerRef = useRef(null);
|
||||
const hasAutoLoadedRef = useRef(false);
|
||||
|
||||
// 投票状态
|
||||
const [voting, setVoting] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
// 加载任务详情
|
||||
const loadTask = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!projectId || !taskId) return;
|
||||
|
||||
try {
|
||||
if (!silent) setLoading(true);
|
||||
setError('');
|
||||
// 添加时间戳防止缓存
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}?t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Pragma: 'no-cache',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
console.log('任务状态更新:', result.data.completedCount, '/', result.data.totalCount);
|
||||
setTask(result.data);
|
||||
// 检查任务是否已完成 (0=进行中, 1=已完成, 2=失败, 3=已中断)
|
||||
if (result.data.status !== 0) {
|
||||
setCompleted(true);
|
||||
}
|
||||
} else {
|
||||
if (!silent) setError(result.error || '加载任务详情失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载任务详情失败:', err);
|
||||
if (!silent) setError('加载任务详情失败');
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, taskId]
|
||||
);
|
||||
|
||||
// 流式获取当前题目和模型回答
|
||||
const fetchCurrentQuestion = useCallback(async () => {
|
||||
if (!projectId || !taskId) return;
|
||||
|
||||
// 取消上一次的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setAnswersLoading(true);
|
||||
setError('');
|
||||
setCurrentQuestion(null);
|
||||
setLeftAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null });
|
||||
setRightAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null });
|
||||
|
||||
// 1. 先获取题目信息
|
||||
const questionRes = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/question`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!questionRes.ok) throw new Error('获取题目失败');
|
||||
|
||||
const questionData = await questionRes.json();
|
||||
|
||||
if (questionData.completed) {
|
||||
setCompleted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentQuestion({
|
||||
id: questionData.questionId,
|
||||
question: questionData.question,
|
||||
answer: questionData.answer,
|
||||
index: questionData.questionIndex,
|
||||
total: questionData.totalQuestions
|
||||
});
|
||||
setIsSwapped(questionData.isSwapped);
|
||||
setCompleted(false);
|
||||
|
||||
// 2. 并行调用两个模型的流式接口
|
||||
setStreamingA(true);
|
||||
setStreamingB(true);
|
||||
|
||||
const processStream = async (modelType, setAnswer, setStreaming) => {
|
||||
const modelStartTime = Date.now();
|
||||
try {
|
||||
const streamUrl = `/api/projects/${projectId}/blind-test-tasks/${taskId}/stream-model?model=${modelType}`;
|
||||
const response = await fetch(streamUrl, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`模型${modelType}调用失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let fullContent = '';
|
||||
let currentContent = '';
|
||||
let currentThinking = '';
|
||||
let isInThinking = false;
|
||||
let pendingBuffer = ''; // 用于处理跨 chunk 的标签识别
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
pendingBuffer += chunk;
|
||||
|
||||
// 处理缓冲区中的内容
|
||||
while (pendingBuffer.length > 0) {
|
||||
// 如果正在思考中,寻找结束标签
|
||||
if (isInThinking) {
|
||||
const endTagIndex = pendingBuffer.indexOf('</think>');
|
||||
if (endTagIndex !== -1) {
|
||||
const thinkingPart = pendingBuffer.substring(0, endTagIndex);
|
||||
currentThinking += thinkingPart;
|
||||
fullContent += thinkingPart + '</think>';
|
||||
isInThinking = false;
|
||||
pendingBuffer = pendingBuffer.substring(endTagIndex + 8);
|
||||
continue;
|
||||
} else {
|
||||
// 没有找到结束标签,但可能缓冲区末尾包含了部分结束标签
|
||||
// 保留最后 7 个字符("</think>" 长度为 8)以防被截断
|
||||
const safeLength = Math.max(0, pendingBuffer.length - 7);
|
||||
const processingPart = pendingBuffer.substring(0, safeLength);
|
||||
currentThinking += processingPart;
|
||||
fullContent += processingPart;
|
||||
pendingBuffer = pendingBuffer.substring(safeLength);
|
||||
break; // 等待下一个 chunk
|
||||
}
|
||||
} else {
|
||||
// 不在思考中,寻找开始标签
|
||||
const startTagIndex = pendingBuffer.indexOf('<think>');
|
||||
if (startTagIndex !== -1) {
|
||||
const contentPart = pendingBuffer.substring(0, startTagIndex);
|
||||
currentContent += contentPart;
|
||||
fullContent += contentPart + '<think>';
|
||||
isInThinking = true;
|
||||
pendingBuffer = pendingBuffer.substring(startTagIndex + 7);
|
||||
continue;
|
||||
} else {
|
||||
// 没有找到开始标签,保留最后 6 个字符以防开始标签被截断
|
||||
const safeLength = Math.max(0, pendingBuffer.length - 6);
|
||||
const processingPart = pendingBuffer.substring(0, safeLength);
|
||||
currentContent += processingPart;
|
||||
fullContent += processingPart;
|
||||
pendingBuffer = pendingBuffer.substring(safeLength);
|
||||
break; // 等待下一个 chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAnswer(prev => ({
|
||||
...prev,
|
||||
fullContent,
|
||||
content: currentContent,
|
||||
thinking: currentThinking,
|
||||
isThinking: isInThinking
|
||||
}));
|
||||
}
|
||||
|
||||
const modelDuration = Date.now() - modelStartTime;
|
||||
setAnswer(prev => ({ ...prev, duration: modelDuration }));
|
||||
setStreaming(false);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error(`模型${modelType}错误:`, err);
|
||||
const modelDuration = Date.now() - modelStartTime;
|
||||
setAnswer(prev => ({
|
||||
...prev,
|
||||
error: err.message,
|
||||
duration: modelDuration
|
||||
}));
|
||||
setStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据是否交换决定左右对应的模型
|
||||
const leftModel = questionData.isSwapped ? 'B' : 'A';
|
||||
const rightModel = questionData.isSwapped ? 'A' : 'B';
|
||||
|
||||
await Promise.all([
|
||||
processStream(leftModel, setLeftAnswer, setStreamingA),
|
||||
processStream(rightModel, setRightAnswer, setStreamingB)
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('获取题目失败:', err);
|
||||
setError(err.message || '获取当前题目失败');
|
||||
setStreamingA(false);
|
||||
setStreamingB(false);
|
||||
} finally {
|
||||
// 只有当前请求未被取消时才重置loading
|
||||
if (abortControllerRef.current === controller) {
|
||||
setAnswersLoading(false);
|
||||
}
|
||||
}
|
||||
}, [projectId, taskId]);
|
||||
|
||||
// 提交投票
|
||||
const submitVote = useCallback(
|
||||
async vote => {
|
||||
if (!projectId || !taskId || !currentQuestion) return { success: false };
|
||||
|
||||
try {
|
||||
setVoting(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
vote,
|
||||
questionId: currentQuestion.id,
|
||||
isSwapped,
|
||||
// 使用 fullContent 提交,包含思考过程
|
||||
leftAnswer: leftAnswer?.fullContent || leftAnswer?.content || '',
|
||||
rightAnswer: rightAnswer?.fullContent || rightAnswer?.content || ''
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
// 等待任务状态更新(进度条)
|
||||
await loadTask(true);
|
||||
|
||||
if (result.data.isCompleted) {
|
||||
setCompleted(true);
|
||||
} else {
|
||||
// 获取下一题
|
||||
await fetchCurrentQuestion();
|
||||
}
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
setError(result.error || '提交投票失败');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('提交投票失败:', err);
|
||||
setError('提交投票失败');
|
||||
return { success: false, error: '提交投票失败' };
|
||||
} finally {
|
||||
setVoting(false);
|
||||
}
|
||||
},
|
||||
[projectId, taskId, currentQuestion, isSwapped, leftAnswer, rightAnswer, loadTask, fetchCurrentQuestion]
|
||||
);
|
||||
|
||||
// 中断任务
|
||||
const interruptTask = useCallback(async () => {
|
||||
if (!projectId || !taskId) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'interrupt' })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setCompleted(true);
|
||||
loadTask();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '中断任务失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('中断任务失败:', err);
|
||||
setError('中断任务失败');
|
||||
return false;
|
||||
}
|
||||
}, [projectId, taskId, loadTask]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadTask();
|
||||
}, [loadTask]);
|
||||
|
||||
// 任务加载完成后,如果任务进行中,自动获取当前题目(只执行一次)
|
||||
useEffect(() => {
|
||||
if (task && task.status === 0 && !completed && !hasAutoLoadedRef.current && projectId && taskId) {
|
||||
hasAutoLoadedRef.current = true;
|
||||
fetchCurrentQuestion();
|
||||
}
|
||||
}, [task, completed, projectId, taskId, fetchCurrentQuestion]);
|
||||
|
||||
// 计算结果统计
|
||||
const getResultStats = useCallback(() => {
|
||||
if (!task?.detail?.results) return null;
|
||||
|
||||
const results = task.detail.results;
|
||||
const totalModelAScore = results.reduce((sum, r) => sum + (r.modelAScore || 0), 0);
|
||||
const totalModelBScore = results.reduce((sum, r) => sum + (r.modelBScore || 0), 0);
|
||||
|
||||
const leftWins = results.filter(r => r.vote === 'left').length;
|
||||
const rightWins = results.filter(r => r.vote === 'right').length;
|
||||
const bothGood = results.filter(r => r.vote === 'both_good').length;
|
||||
const bothBad = results.filter(r => r.vote === 'both_bad').length;
|
||||
|
||||
// 计算实际模型胜出次数(需要考虑 swap)
|
||||
const modelAWins = results.filter(r => {
|
||||
if (r.vote === 'left' && !r.isSwapped) return true;
|
||||
if (r.vote === 'right' && r.isSwapped) return true;
|
||||
return false;
|
||||
}).length;
|
||||
|
||||
const modelBWins = results.filter(r => {
|
||||
if (r.vote === 'left' && r.isSwapped) return true;
|
||||
if (r.vote === 'right' && !r.isSwapped) return true;
|
||||
return false;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
totalQuestions: results.length,
|
||||
modelAScore: totalModelAScore,
|
||||
modelBScore: totalModelBScore,
|
||||
modelAWins,
|
||||
modelBWins,
|
||||
ties: bothGood + bothBad,
|
||||
bothGood,
|
||||
bothBad,
|
||||
leftWins,
|
||||
rightWins
|
||||
};
|
||||
}, [task]);
|
||||
|
||||
// 组件卸载时取消请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 任务详情
|
||||
task,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadTask,
|
||||
|
||||
// 当前题目状态
|
||||
currentQuestion,
|
||||
leftAnswer,
|
||||
rightAnswer,
|
||||
answersLoading,
|
||||
|
||||
// 流式状态
|
||||
streamingA,
|
||||
streamingB,
|
||||
|
||||
// 投票状态
|
||||
voting,
|
||||
completed,
|
||||
|
||||
// 操作
|
||||
fetchCurrentQuestion,
|
||||
submitVote,
|
||||
interruptTask,
|
||||
|
||||
// 结果统计
|
||||
getResultStats
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 盲测任务列表管理 Hook
|
||||
*/
|
||||
export default function useBlindTestTasks(projectId) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(6);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = useCallback(
|
||||
async (isRefresh = false) => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
if (!isRefresh) setLoading(true);
|
||||
setError('');
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks?page=${page}&pageSize=${pageSize}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setTasks(result.data.items || []);
|
||||
setTotal(result.data.total || 0);
|
||||
} else {
|
||||
setError(result.error || '加载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载盲测任务失败:', err);
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
if (!isRefresh) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, page, pageSize]
|
||||
);
|
||||
|
||||
// 初始加载和分页变化加载
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// 删除任务
|
||||
const deleteTask = useCallback(
|
||||
async taskId => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '删除失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('删除任务失败:', err);
|
||||
setError('删除失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
// 中断任务
|
||||
const interruptTask = useCallback(
|
||||
async taskId => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'interrupt' })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '中断失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('中断任务失败:', err);
|
||||
setError('中断失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
// 创建任务
|
||||
const createTask = useCallback(
|
||||
async taskData => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(taskData)
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: result.error || '创建失败' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建任务失败:', err);
|
||||
return { success: false, error: '创建失败' };
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadTasks,
|
||||
deleteTask,
|
||||
interruptTask,
|
||||
createTask,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
total
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
TablePagination
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useBlindTestTasks from './hooks/useBlindTestTasks';
|
||||
import BlindTestTaskCard from './components/BlindTestTaskCard';
|
||||
import CreateBlindTestDialog from './components/CreateBlindTestDialog';
|
||||
|
||||
export default function BlindTestTasksPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
deleteTask,
|
||||
interruptTask,
|
||||
createTask,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
total
|
||||
} = useBlindTestTasks(projectId);
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null });
|
||||
const [interruptDialog, setInterruptDialog] = useState({ open: false, task: null });
|
||||
|
||||
const handleView = task => router.push(`/projects/${projectId}/blind-test-tasks/${task.id}`);
|
||||
const handleContinue = task => router.push(`/projects/${projectId}/blind-test-tasks/${task.id}`);
|
||||
const handleDelete = task => setDeleteDialog({ open: true, task });
|
||||
const handleInterrupt = task => setInterruptDialog({ open: true, task });
|
||||
|
||||
const handlePageChange = (event, newPage) => {
|
||||
setPage(newPage + 1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = event => {
|
||||
setPageSize(parseInt(event.target.value, 10));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteDialog.task) {
|
||||
await deleteTask(deleteDialog.task.id);
|
||||
}
|
||||
setDeleteDialog({ open: false, task: null });
|
||||
};
|
||||
|
||||
const confirmInterrupt = async () => {
|
||||
if (interruptDialog.task) {
|
||||
await interruptTask(interruptDialog.task.id);
|
||||
}
|
||||
setInterruptDialog({ open: false, task: null });
|
||||
};
|
||||
|
||||
const handleCreate = async taskData => {
|
||||
const result = await createTask(taskData);
|
||||
if (result.success) {
|
||||
// 创建成功后跳转到任务详情页开始盲测
|
||||
router.push(`/projects/${projectId}/blind-test-tasks/${result.data.id}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
{/* 页面标题 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CompareArrowsIcon sx={{ fontSize: 28, color: 'primary.main' }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
{t('blindTest.title', '人工盲测任务')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)}>
|
||||
{t('blindTest.createTask', '创建任务')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 8,
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<CompareArrowsIcon sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('blindTest.noTasks', '暂无盲测任务')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled" sx={{ mb: 3 }}>
|
||||
{t('blindTest.noTasksHint', '创建盲测任务来对比两个模型的回答质量')}
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)}>
|
||||
{t('blindTest.createTask', '创建任务')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 任务列表 */}
|
||||
{!loading && tasks.length > 0 && (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
{tasks.map(task => (
|
||||
<Grid item xs={12} key={task.id}>
|
||||
<BlindTestTaskCard
|
||||
task={task}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
onInterrupt={handleInterrupt}
|
||||
onContinue={handleContinue}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page - 1}
|
||||
onPageChange={handlePageChange}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={handlePageSizeChange}
|
||||
rowsPerPageOptions={[6, 12, 24, 48]}
|
||||
labelRowsPerPage={t('datasets.rowsPerPage', '每页行数')}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建对话框 */}
|
||||
<CreateBlindTestDialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
projectId={projectId}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, task: null })}>
|
||||
<DialogTitle>{t('blindTest.deleteConfirmTitle', '确认删除')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('blindTest.deleteConfirmMessage', '确定要删除这个盲测任务吗?此操作不可撤销。')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, task: null })}>{t('common.cancel', '取消')}</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete}>
|
||||
{t('common.delete', '删除')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 中断确认对话框 */}
|
||||
<Dialog open={interruptDialog.open} onClose={() => setInterruptDialog({ open: false, task: null })}>
|
||||
<DialogTitle>{t('blindTest.interruptConfirmTitle', '确认中断')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('blindTest.interruptConfirmMessage', '确定要中断这个盲测任务吗?已完成的评判结果将保留。')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setInterruptDialog({ open: false, task: null })}>{t('common.cancel', '取消')}</Button>
|
||||
<Button color="warning" variant="contained" onClick={confirmInterrupt}>
|
||||
{t('blindTest.interrupt', '中断')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Box, Typography, Alert, Snackbar, Paper } from '@mui/material';
|
||||
import { useEffect } from 'react';
|
||||
import ChunkViewDialog from '@/components/text-split/ChunkViewDialog';
|
||||
import DatasetHeader from '@/components/datasets/DatasetHeader';
|
||||
import DatasetMetadata from '@/components/datasets/DatasetMetadata';
|
||||
import EditableField from '@/components/datasets/EditableField';
|
||||
import OptimizeDialog from '@/components/datasets/OptimizeDialog';
|
||||
import DatasetRatingSection from '@/components/datasets/DatasetRatingSection';
|
||||
import useDatasetDetails from '@/app/projects/[projectId]/datasets/[datasetId]/useDatasetDetails';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 数据集详情页面
|
||||
*/
|
||||
export default function DatasetDetailsPage({ params }) {
|
||||
const { projectId, datasetId } = params;
|
||||
|
||||
const { t } = useTranslation();
|
||||
// 使用自定义Hook管理状态和逻辑
|
||||
const {
|
||||
currentDataset,
|
||||
loading,
|
||||
editingAnswer,
|
||||
editingCot,
|
||||
editingQuestion,
|
||||
answerValue,
|
||||
cotValue,
|
||||
questionValue,
|
||||
snackbar,
|
||||
confirming,
|
||||
unconfirming,
|
||||
optimizeDialog,
|
||||
viewDialogOpen,
|
||||
viewChunk,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
answerTokens,
|
||||
cotTokens,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
setSnackbar,
|
||||
setAnswerValue,
|
||||
setCotValue,
|
||||
setQuestionValue,
|
||||
setEditingAnswer,
|
||||
setEditingCot,
|
||||
setEditingQuestion,
|
||||
handleNavigate,
|
||||
handleConfirm,
|
||||
handleUnconfirm,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
handleOpenOptimizeDialog,
|
||||
handleCloseOptimizeDialog,
|
||||
handleOptimize,
|
||||
handleViewChunk,
|
||||
handleCloseViewDialog
|
||||
} = useDatasetDetails(projectId, datasetId);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
|
||||
<Alert severity="info">{t('datasets.loadingDataset')}</Alert>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据状态
|
||||
if (!currentDataset) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{t('datasets.datasetNotFound')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* 顶部导航栏 */}
|
||||
<DatasetHeader
|
||||
projectId={projectId}
|
||||
datasetsAllCount={datasetsAllCount}
|
||||
datasetsConfirmCount={datasetsConfirmCount}
|
||||
confirming={confirming}
|
||||
unconfirming={unconfirming}
|
||||
currentDataset={currentDataset}
|
||||
shortcutsEnabled={shortcutsEnabled}
|
||||
setShortcutsEnabled={setShortcutsEnabled}
|
||||
onNavigate={handleNavigate}
|
||||
onConfirm={handleConfirm}
|
||||
onUnconfirm={handleUnconfirm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 主要布局:左右分栏 */}
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* 左侧主要内容区域 */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<EditableField
|
||||
label={t('datasets.question')}
|
||||
value={questionValue}
|
||||
editing={editingQuestion}
|
||||
onEdit={() => setEditingQuestion(true)}
|
||||
onChange={e => setQuestionValue(e.target.value)}
|
||||
onSave={() => handleSave('question', questionValue)}
|
||||
dataset={currentDataset}
|
||||
onCancel={() => {
|
||||
setEditingQuestion(false);
|
||||
setQuestionValue(currentDataset.question);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditableField
|
||||
label={t('datasets.answer')}
|
||||
value={answerValue}
|
||||
editing={editingAnswer}
|
||||
onEdit={() => setEditingAnswer(true)}
|
||||
onChange={e => setAnswerValue(e.target.value)}
|
||||
onSave={() => handleSave('answer', answerValue)}
|
||||
onCancel={() => {
|
||||
setEditingAnswer(false);
|
||||
setAnswerValue(currentDataset.answer);
|
||||
}}
|
||||
dataset={currentDataset}
|
||||
onOptimize={handleOpenOptimizeDialog}
|
||||
tokenCount={answerTokens}
|
||||
optimizing={optimizeDialog.loading}
|
||||
/>
|
||||
|
||||
<EditableField
|
||||
label={t('datasets.cot')}
|
||||
value={cotValue}
|
||||
editing={editingCot}
|
||||
onEdit={() => setEditingCot(true)}
|
||||
onChange={e => setCotValue(e.target.value)}
|
||||
onSave={() => handleSave('cot', cotValue)}
|
||||
dataset={currentDataset}
|
||||
onCancel={() => {
|
||||
setEditingCot(false);
|
||||
setCotValue(currentDataset.cot || '');
|
||||
}}
|
||||
tokenCount={cotTokens}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* 右侧固定侧边栏 */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 360,
|
||||
position: 'sticky',
|
||||
top: 24,
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* 数据集元数据信息 */}
|
||||
<DatasetMetadata currentDataset={currentDataset} onViewChunk={handleViewChunk} />
|
||||
|
||||
{/* 评分、标签、备注区域 */}
|
||||
<DatasetRatingSection
|
||||
dataset={currentDataset}
|
||||
projectId={projectId}
|
||||
onUpdate={() => {
|
||||
// 更新成功后刷新数据,保持页面状态同步
|
||||
// 这里可以调用 useDatasetDetails 的刷新逻辑
|
||||
}}
|
||||
currentDataset={currentDataset}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 消息提示 */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
|
||||
severity={snackbar.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
{/* AI优化对话框 */}
|
||||
<OptimizeDialog open={optimizeDialog.open} onClose={handleCloseOptimizeDialog} onConfirm={handleOptimize} />
|
||||
|
||||
{/* 文本块详情对话框 */}
|
||||
<ChunkViewDialog open={viewDialogOpen} chunk={viewChunk} onClose={handleCloseViewDialog} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import i18n from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* 数据集详情页面业务逻辑 Hook
|
||||
*/
|
||||
export default function useDatasetDetails(projectId, datasetId) {
|
||||
const router = useRouter();
|
||||
const [datasets, setDatasets] = useState([]);
|
||||
const [currentDataset, setCurrentDataset] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingAnswer, setEditingAnswer] = useState(false);
|
||||
const [editingCot, setEditingCot] = useState(false);
|
||||
const [editingQuestion, setEditingQuestion] = useState(false);
|
||||
const [answerValue, setAnswerValue] = useState('');
|
||||
const [cotValue, setCotValue] = useState('');
|
||||
const [questionValue, setQuestionValue] = useState('');
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success'
|
||||
});
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [unconfirming, setUnconfirming] = useState(false);
|
||||
const [optimizeDialog, setOptimizeDialog] = useState({
|
||||
open: false,
|
||||
loading: false
|
||||
});
|
||||
const [viewDialogOpen, setViewDialogOpen] = useState(false);
|
||||
const [viewChunk, setViewChunk] = useState(null);
|
||||
const [datasetsAllCount, setDatasetsAllCount] = useState(0);
|
||||
const [datasetsConfirmCount, setDatasetsConfirmCount] = useState(0);
|
||||
const [answerTokens, setAnswerTokens] = useState(0);
|
||||
const [cotTokens, setCotTokens] = useState(0);
|
||||
const model = useAtomValue(selectedModelInfoAtom);
|
||||
const [shortcutsEnabled, setShortcutsEnabled] = useState(() => {
|
||||
const storedValue = localStorage.getItem('shortcutsEnabled');
|
||||
return storedValue !== null ? storedValue === 'true' : false;
|
||||
});
|
||||
|
||||
// 输入环境判断,避免在输入框/可编辑区域误触快捷键
|
||||
const isEditableTarget = el => {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName?.toLowerCase();
|
||||
if (tag && ['input', 'textarea', 'select'].includes(tag)) return true;
|
||||
if (el.isContentEditable) return true;
|
||||
// 兼容嵌套的可编辑区域与常见富文本编辑器
|
||||
return !!el.closest?.('[contenteditable="true"], .ProseMirror, .ql-editor');
|
||||
};
|
||||
|
||||
// 简单节流,避免连续触发
|
||||
const lastShortcutRef = useRef(0);
|
||||
|
||||
// 异步获取Token数量
|
||||
const fetchTokenCount = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/${datasetId}/token-count`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.answerTokens !== undefined) {
|
||||
setAnswerTokens(data.answerTokens);
|
||||
}
|
||||
if (data.cotTokens !== undefined) {
|
||||
setCotTokens(data.cotTokens);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取Token数量失败:', error);
|
||||
// Token加载失败不阻塞主界面或显示错误提示
|
||||
}
|
||||
};
|
||||
|
||||
// 获取数据集详情
|
||||
const fetchDatasets = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/${datasetId}`);
|
||||
if (!response.ok) throw new Error('获取数据集详情失败');
|
||||
const data = await response.json();
|
||||
setCurrentDataset(data.datasets);
|
||||
setCotValue(data.datasets?.cot);
|
||||
setAnswerValue(data.datasets?.answer);
|
||||
setQuestionValue(data.datasets?.question);
|
||||
setDatasetsAllCount(data.total);
|
||||
setDatasetsConfirmCount(data.confirmedCount);
|
||||
|
||||
// 数据加载完成后,异步获取Token数量
|
||||
fetchTokenCount();
|
||||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.message,
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 确认并保存数据集
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setConfirming(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirmed: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('操作失败');
|
||||
}
|
||||
|
||||
setCurrentDataset(prev => ({ ...prev, confirmed: true }));
|
||||
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: '操作成功',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// 导航到下一个数据集
|
||||
handleNavigate('next');
|
||||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.message || '操作失败',
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消确认数据集
|
||||
const handleUnconfirm = async () => {
|
||||
try {
|
||||
setUnconfirming(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirmed: false
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('操作失败');
|
||||
}
|
||||
|
||||
setCurrentDataset(prev => ({ ...prev, confirmed: false }));
|
||||
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: '已取消确认',
|
||||
severity: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.message || '取消确认失败',
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
setUnconfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 导航到其他数据集
|
||||
const handleNavigate = async direction => {
|
||||
const response = await axios.get(`/api/projects/${projectId}/datasets/${datasetId}?operateType=${direction}`);
|
||||
if (response.data) {
|
||||
router.push(`/projects/${projectId}/datasets/${response.data.id}`);
|
||||
} else {
|
||||
toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条数据了`);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSave = async (field, value) => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
[field]: value
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setCurrentDataset(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: '保存成功',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// 重置编辑状态
|
||||
if (field === 'answer') setEditingAnswer(false);
|
||||
if (field === 'cot') setEditingCot(false);
|
||||
if (field === 'question') setEditingQuestion(false);
|
||||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.message || '保存失败',
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据集
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('确定要删除这条数据吗?此操作不可撤销。')) return;
|
||||
|
||||
try {
|
||||
// 尝试获取下一个数据集,在删除前先确保有可导航的目标
|
||||
const nextResponse = await axios.get(`/api/projects/${projectId}/datasets/${datasetId}?operateType=next`);
|
||||
const hasNextDataset = !!nextResponse.data;
|
||||
const nextDatasetId = hasNextDataset ? nextResponse.data.id : null;
|
||||
|
||||
// 删除当前数据集
|
||||
const deleteResponse = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
throw new Error('删除失败');
|
||||
}
|
||||
|
||||
// 导航逻辑:有下一个就跳转下一个,没有则返回列表页
|
||||
if (hasNextDataset) {
|
||||
router.push(`/projects/${projectId}/datasets/${nextDatasetId}`);
|
||||
} else {
|
||||
// 没有更多数据集,返回列表页面
|
||||
router.push(`/projects/${projectId}/datasets`);
|
||||
}
|
||||
|
||||
toast.success('删除成功');
|
||||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.message || '删除失败',
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 优化对话框相关操作
|
||||
const handleOpenOptimizeDialog = () => {
|
||||
setOptimizeDialog({
|
||||
open: true,
|
||||
loading: false
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseOptimizeDialog = () => {
|
||||
setOptimizeDialog(prev => {
|
||||
// 如果正在优化,不允许关闭
|
||||
if (prev.loading) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
open: false,
|
||||
loading: false
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 优化操作
|
||||
const handleOptimize = async advice => {
|
||||
if (!model) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: '请先选择模型,可以在顶部导航栏选择',
|
||||
severity: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 立即关闭对话框,并设置优化中状态
|
||||
setOptimizeDialog(prev => {
|
||||
const newState = {
|
||||
open: false,
|
||||
loading: true
|
||||
};
|
||||
return newState;
|
||||
});
|
||||
|
||||
toast.info('已开始优化,请稍候...');
|
||||
|
||||
// 异步后台处理,不等待结果
|
||||
(async () => {
|
||||
try {
|
||||
const language = i18n.language === 'zh-CN' ? '中文' : 'en';
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/optimize`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
datasetId,
|
||||
model,
|
||||
advice,
|
||||
language
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '优化失败');
|
||||
}
|
||||
|
||||
// 优化成功后,重新查询数据以获取最新状态
|
||||
await fetchDatasets();
|
||||
// 优化可能改变了文本内容,重新获取Token计数
|
||||
fetchTokenCount();
|
||||
|
||||
toast.success('AI智能优化成功');
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setOptimizeDialog({
|
||||
open: false,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// 查看文本块详情
|
||||
const handleViewChunk = async chunkContent => {
|
||||
try {
|
||||
setViewChunk(chunkContent);
|
||||
setViewDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('查看文本块出错', error);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.message,
|
||||
severity: 'error'
|
||||
});
|
||||
setViewDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭文本块详情对话框
|
||||
const handleCloseViewDialog = () => {
|
||||
setViewDialogOpen(false);
|
||||
};
|
||||
|
||||
// 初始化和快捷键事件
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, [projectId, datasetId]);
|
||||
|
||||
// 快捷键状态变化
|
||||
useEffect(() => {
|
||||
localStorage.setItem('shortcutsEnabled', shortcutsEnabled);
|
||||
}, [shortcutsEnabled]);
|
||||
|
||||
// 监听键盘事件
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
if (!shortcutsEnabled) return;
|
||||
|
||||
// 在输入框或可编辑区域时不触发
|
||||
const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (isEditableTarget(event.target) || isEditableTarget(activeEl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅要求 Shift 修饰键,降低误触且更简单
|
||||
if (!event.shiftKey) return;
|
||||
|
||||
// 简单节流,过滤极短时间内重复触发
|
||||
const now = Date.now();
|
||||
if (now - (lastShortcutRef.current || 0) < 250) {
|
||||
return;
|
||||
}
|
||||
lastShortcutRef.current = now;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft': // 上一个(Shift + ArrowLeft)
|
||||
event.preventDefault();
|
||||
handleNavigate('prev');
|
||||
break;
|
||||
case 'ArrowRight': // 下一个(Shift + ArrowRight)
|
||||
event.preventDefault();
|
||||
handleNavigate('next');
|
||||
break;
|
||||
case 'y': // 确认(Shift + Y)
|
||||
case 'Y':
|
||||
if (!confirming && currentDataset && !currentDataset.confirmed) {
|
||||
event.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
break;
|
||||
case 'd': // 删除(Shift + D)
|
||||
case 'D':
|
||||
event.preventDefault();
|
||||
handleDelete();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [shortcutsEnabled, confirming, currentDataset]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
currentDataset,
|
||||
answerValue,
|
||||
cotValue,
|
||||
questionValue,
|
||||
editingAnswer,
|
||||
editingCot,
|
||||
editingQuestion,
|
||||
confirming,
|
||||
unconfirming,
|
||||
snackbar,
|
||||
optimizeDialog,
|
||||
viewDialogOpen,
|
||||
viewChunk,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
answerTokens,
|
||||
cotTokens,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
setSnackbar,
|
||||
setAnswerValue,
|
||||
setCotValue,
|
||||
setQuestionValue,
|
||||
setEditingAnswer,
|
||||
setEditingCot,
|
||||
setEditingQuestion,
|
||||
handleNavigate,
|
||||
handleConfirm,
|
||||
handleUnconfirm,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
handleOpenOptimizeDialog,
|
||||
handleCloseOptimizeDialog,
|
||||
handleOptimize,
|
||||
handleViewChunk,
|
||||
handleCloseViewDialog
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button } from '@mui/material';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ActionBar = ({ onBatchEvaluate, onImport, onExport, batchEvaluating = false }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AssessmentIcon />}
|
||||
sx={{ borderRadius: 2 }}
|
||||
onClick={onBatchEvaluate}
|
||||
disabled={batchEvaluating}
|
||||
>
|
||||
{batchEvaluating ? t('datasets.evaluating', '评估中...') : t('datasets.batchEvaluate', '批量评估')}
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<FileUploadIcon />} sx={{ borderRadius: 2 }} onClick={onImport}>
|
||||
{t('import.title', '导入')}
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<FileDownloadIcon />} sx={{ borderRadius: 2 }} onClick={onExport}>
|
||||
{t('export.title')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionBar;
|
||||
@@ -0,0 +1,422 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Divider,
|
||||
useTheme,
|
||||
alpha,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
TablePagination,
|
||||
TextField,
|
||||
Card,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getRatingConfigI18n, formatScore } from '@/components/datasets/utils/ratingUtils';
|
||||
|
||||
// 数据集列表组件
|
||||
const DatasetList = ({
|
||||
datasets,
|
||||
onViewDetails,
|
||||
onDelete,
|
||||
onEvaluate,
|
||||
page,
|
||||
rowsPerPage,
|
||||
onPageChange,
|
||||
onRowsPerPageChange,
|
||||
total,
|
||||
selectedIds,
|
||||
onSelectAll,
|
||||
onSelectItem,
|
||||
evaluatingIds = [],
|
||||
loading = false
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bgColor = theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light;
|
||||
const color =
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.getContrastText(theme.palette.primary.main)
|
||||
: theme.palette.getContrastText(theme.palette.primary.contrastText);
|
||||
|
||||
const RatingChip = ({ score }) => {
|
||||
const config = getRatingConfigI18n(score, t);
|
||||
return (
|
||||
<Chip
|
||||
icon={<StarIcon sx={{ fontSize: '14px !important' }} />}
|
||||
label={`${formatScore(score)} ${config.label}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: config.backgroundColor,
|
||||
color: config.color,
|
||||
fontWeight: 'medium',
|
||||
'& .MuiChip-icon': {
|
||||
color: config.color
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card elevation={2}>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<TableContainer sx={{ overflowX: 'auto' }}>
|
||||
<Table sx={{ minWidth: 900 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
padding="checkbox"
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
borderBottom: `2px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
indeterminate={selectedIds.length > 0 && selectedIds.length < total}
|
||||
checked={total > 0 && selectedIds.length === total}
|
||||
onChange={onSelectAll}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
fontWeight: 'bold',
|
||||
padding: '16px 8px',
|
||||
borderBottom: `2px solid ${theme.palette.divider}`,
|
||||
minWidth: 200
|
||||
}}
|
||||
>
|
||||
{t('datasets.question')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
fontWeight: 'bold',
|
||||
padding: '16px 8px',
|
||||
borderBottom: `2px solid ${theme.palette.divider}`,
|
||||
width: 120
|
||||
}}
|
||||
>
|
||||
{t('datasets.rating', '评分')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
fontWeight: 'bold',
|
||||
padding: '16px 8px',
|
||||
borderBottom: `2px solid ${theme.palette.divider}`,
|
||||
width: 100
|
||||
}}
|
||||
>
|
||||
{t('datasets.model')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
fontWeight: 'bold',
|
||||
padding: '16px 8px',
|
||||
borderBottom: `2px solid ${theme.palette.divider}`,
|
||||
width: 100
|
||||
}}
|
||||
>
|
||||
{t('datasets.domainTag')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
fontWeight: 'bold',
|
||||
padding: '16px 8px',
|
||||
borderBottom: `2px solid ${theme.palette.divider}`,
|
||||
width: 120
|
||||
}}
|
||||
>
|
||||
{t('datasets.createdAt')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
fontWeight: 'bold',
|
||||
padding: '16px 8px',
|
||||
borderBottom: `2px solid ${theme.palette.divider}`,
|
||||
width: 120
|
||||
}}
|
||||
>
|
||||
{t('common.actions')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{datasets.map((dataset, index) => (
|
||||
<>
|
||||
<TableRow
|
||||
key={dataset.id}
|
||||
sx={{
|
||||
'&:nth-of-type(odd)': { backgroundColor: alpha(theme.palette.primary.light, 0.05) },
|
||||
'&:hover': { backgroundColor: alpha(theme.palette.primary.light, 0.1) },
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => onViewDetails(dataset.id)}
|
||||
>
|
||||
<TableCell
|
||||
padding="checkbox"
|
||||
sx={{
|
||||
borderLeft: `4px solid ${theme.palette.primary.main}`
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
checked={selectedIds.includes(dataset.id)}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
onSelectItem(dataset.id);
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 2 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="medium"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: 1.4,
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{dataset.question}
|
||||
</Typography>
|
||||
{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',
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
mt: 1
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RatingChip score={dataset.score || 0} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={dataset.model}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.info.main, 0.1),
|
||||
color: theme.palette.info.dark,
|
||||
fontWeight: 'medium',
|
||||
maxWidth: '100%',
|
||||
'& .MuiChip-label': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dataset.questionLabel ? (
|
||||
<Chip
|
||||
label={dataset.questionLabel}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
color: theme.palette.primary.dark,
|
||||
fontWeight: 'medium',
|
||||
maxWidth: '100%',
|
||||
'& .MuiChip-label': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled" fontSize="0.75rem">
|
||||
{t('datasets.noTag')}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary" fontSize="0.75rem">
|
||||
{new Date(dataset.createAt).toLocaleDateString('zh-CN')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={t('datasets.viewDetails')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onViewDetails(dataset.id);
|
||||
}}
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
'&:hover': { backgroundColor: alpha(theme.palette.primary.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('datasets.evaluate')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={evaluatingIds.includes(dataset.id)}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEvaluate && onEvaluate(dataset);
|
||||
}}
|
||||
sx={{
|
||||
color: theme.palette.secondary.main,
|
||||
'&:hover': { backgroundColor: alpha(theme.palette.secondary.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
{evaluatingIds.includes(dataset.id) ? (
|
||||
<CircularProgress size={20} sx={{ color: theme.palette.secondary.main }} />
|
||||
) : (
|
||||
<AssessmentIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(dataset);
|
||||
}}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': { backgroundColor: alpha(theme.palette.error.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
))}
|
||||
{datasets.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('datasets.noData')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{loading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: alpha(theme.palette.background.paper, 0.6),
|
||||
backdropFilter: 'blur(2px)',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
<Typography variant="body2" sx={{ mt: 1 }} color="text.secondary">
|
||||
{t('datasets.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderTop: `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page - 1}
|
||||
onPageChange={onPageChange}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={onRowsPerPageChange}
|
||||
labelRowsPerPage={t('datasets.rowsPerPage')}
|
||||
labelDisplayedRows={({ from, to, count }) => t('datasets.pagination', { from, to, count })}
|
||||
sx={{
|
||||
'.MuiTablePagination-selectLabel, .MuiTablePagination-displayedRows': {
|
||||
fontWeight: 'medium'
|
||||
},
|
||||
border: 'none'
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2">{t('common.jumpTo')}:</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: Math.ceil(total / rowsPerPage),
|
||||
style: { padding: '4px 8px', width: '50px' }
|
||||
}}
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const pageNum = parseInt(e.target.value, 10);
|
||||
if (pageNum >= 1 && pageNum <= Math.ceil(total / rowsPerPage)) {
|
||||
onPageChange(null, pageNum - 1);
|
||||
e.target.value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatasetList;
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Typography,
|
||||
Paper,
|
||||
Box,
|
||||
LinearProgress,
|
||||
Button,
|
||||
useTheme,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DeleteConfirmDialog = ({ open, datasets, onClose, onConfirm, batch, progress, deleting }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const dataset = datasets?.[0];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{
|
||||
elevation: 3,
|
||||
sx: { borderRadius: 2 }
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('common.confirmDelete')}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pb: 2, pt: 1 }}>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{batch
|
||||
? t('datasets.batchconfirmDeleteMessage', {
|
||||
count: datasets.length
|
||||
})
|
||||
: t('common.confirmDeleteDataSet')}
|
||||
</Typography>
|
||||
{batch ? (
|
||||
''
|
||||
) : (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: alpha(theme.palette.warning.light, 0.1),
|
||||
borderColor: theme.palette.warning.light
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" color="text.secondary" fontWeight="bold">
|
||||
{t('datasets.question')}:
|
||||
</Typography>
|
||||
<Typography variant="body2">{dataset?.question}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{deleting && progress ? (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body1" sx={{ mr: 1 }}>
|
||||
{progress.percentage}%
|
||||
</Typography>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percentage}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.palette.primary.main
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('datasets.deletingProgress', '正在删除 {{completed}}/{{total}} 个数据集...', {
|
||||
completed: progress.completed,
|
||||
total: progress.total
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={deleting} sx={{ borderRadius: 2 }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} variant="contained" color="error" disabled={deleting} sx={{ borderRadius: 2 }}>
|
||||
{deleting ? t('common.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteConfirmDialog;
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
Slider,
|
||||
TextField,
|
||||
Button,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const FilterDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
filterConfirmed,
|
||||
filterHasCot,
|
||||
filterIsDistill,
|
||||
filterScoreRange,
|
||||
filterCustomTag,
|
||||
filterNoteKeyword,
|
||||
filterChunkName,
|
||||
availableTags,
|
||||
onFilterConfirmedChange,
|
||||
onFilterHasCotChange,
|
||||
onFilterIsDistillChange,
|
||||
onFilterScoreRangeChange,
|
||||
onFilterCustomTagChange,
|
||||
onFilterNoteKeywordChange,
|
||||
onFilterChunkNameChange,
|
||||
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: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('datasets.filterConfirmationStatus')}
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterConfirmed}
|
||||
onChange={e => onFilterConfirmedChange(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem value="all">{t('datasets.filterAll')}</MenuItem>
|
||||
<MenuItem value="confirmed">{t('datasets.filterConfirmed')}</MenuItem>
|
||||
<MenuItem value="unconfirmed">{t('datasets.filterUnconfirmed')}</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('datasets.filterCotStatus')}
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterHasCot}
|
||||
onChange={e => onFilterHasCotChange(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem value="all">{t('datasets.filterAll')}</MenuItem>
|
||||
<MenuItem value="yes">{t('datasets.filterHasCot')}</MenuItem>
|
||||
<MenuItem value="no">{t('datasets.filterNoCot')}</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('datasets.filterDistill')}
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterIsDistill}
|
||||
onChange={e => onFilterIsDistillChange(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem value="all">{t('datasets.filterAll')}</MenuItem>
|
||||
<MenuItem value="yes">{t('datasets.filterDistillYes')}</MenuItem>
|
||||
<MenuItem value="no">{t('datasets.filterDistillNo')}</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('datasets.filterScoreRange')}
|
||||
</Typography>
|
||||
<Box sx={{ px: 1, mt: 2 }}>
|
||||
<Slider
|
||||
value={filterScoreRange}
|
||||
onChange={(_, newValue) => onFilterScoreRangeChange(newValue)}
|
||||
valueLabelDisplay="auto"
|
||||
min={0}
|
||||
max={5}
|
||||
step={0.5}
|
||||
marks={[
|
||||
{ value: 0, label: '0' },
|
||||
{ value: 2.5, label: '2.5' },
|
||||
{ value: 5, label: '5' }
|
||||
]}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('datasets.scoreRange', '{{min}} - {{max}} 分', {
|
||||
min: filterScoreRange[0],
|
||||
max: filterScoreRange[1]
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('datasets.filterCustomTag')}
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterCustomTag}
|
||||
onChange={e => onFilterCustomTagChange(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem value="">{t('datasets.filterAll')}</MenuItem>
|
||||
{availableTags.map(tag => (
|
||||
<MenuItem key={tag} value={tag}>
|
||||
{tag}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('datasets.filterNoteKeyword')}
|
||||
</Typography>
|
||||
<TextField
|
||||
value={filterNoteKeyword}
|
||||
onChange={e => onFilterNoteKeywordChange(e.target.value)}
|
||||
placeholder={t('datasets.filterNoteKeywordPlaceholder')}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('datasets.filterChunkName')}
|
||||
</Typography>
|
||||
<TextField
|
||||
value={filterChunkName}
|
||||
onChange={e => onFilterChunkNameChange(e.target.value)}
|
||||
placeholder={t('datasets.filterChunkNamePlaceholder')}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onResetFilters}>{t('datasets.resetFilters')}</Button>
|
||||
<Button onClick={onApplyFilters} variant="contained">
|
||||
{t('datasets.applyFilters')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterDialog;
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Paper, IconButton, InputBase, Select, MenuItem, Button, Badge } from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SearchBar = ({
|
||||
searchQuery,
|
||||
searchField,
|
||||
onSearchQueryChange,
|
||||
onSearchFieldChange,
|
||||
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('datasets.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchQueryChange(e.target.value)}
|
||||
endAdornment={
|
||||
<Select
|
||||
value={searchField}
|
||||
onChange={e => onSearchFieldChange(e.target.value)}
|
||||
variant="standard"
|
||||
sx={{
|
||||
minWidth: 90,
|
||||
'& .MuiInput-underline:before': { borderBottom: 'none' },
|
||||
'& .MuiInput-underline:after': { borderBottom: 'none' },
|
||||
'& .MuiInput-underline:hover:not(.Mui-disabled):before': { borderBottom: 'none' }
|
||||
}}
|
||||
disableUnderline
|
||||
>
|
||||
<MenuItem value="question">{t('datasets.fieldQuestion')}</MenuItem>
|
||||
<MenuItem value="answer">{t('datasets.fieldAnswer')}</MenuItem>
|
||||
<MenuItem value="cot">{t('datasets.fieldCOT')}</MenuItem>
|
||||
<MenuItem value="questionLabel">{t('datasets.fieldLabel')}</MenuItem>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
<Badge badgeContent={activeFilterCount} color="error" overlap="circular">
|
||||
<Button variant="outlined" onClick={onMoreFiltersClick} startIcon={<FilterListIcon />} sx={{ borderRadius: 2 }}>
|
||||
{t('datasets.moreFilters')}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
/**
|
||||
* 数据集评估相关的自定义 Hook
|
||||
* 封装单个评估和批量评估的逻辑
|
||||
*/
|
||||
const useDatasetEvaluation = (projectId, onEvaluationComplete) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const model = useAtomValue(selectedModelInfoAtom);
|
||||
|
||||
// 评估状态管理
|
||||
const [evaluatingIds, setEvaluatingIds] = useState([]);
|
||||
const [batchEvaluating, setBatchEvaluating] = useState(false);
|
||||
|
||||
/**
|
||||
* 检查模型是否已配置
|
||||
*/
|
||||
const checkModelConfiguration = () => {
|
||||
if (!model || !model.modelName) {
|
||||
toast.error(t('datasets.selectModelFirst', '请先选择模型'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理单个数据集评估
|
||||
* @param {Object} dataset - 要评估的数据集对象
|
||||
*/
|
||||
const handleEvaluateDataset = async dataset => {
|
||||
// 检查模型配置
|
||||
if (!checkModelConfiguration()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 添加到评估中的ID列表
|
||||
setEvaluatingIds(prev => [...prev, dataset.id]);
|
||||
|
||||
// 调用评估接口
|
||||
const evaluateResponse = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}/evaluate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
language: 'zh-CN'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await evaluateResponse.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t('datasets.evaluateSuccess', '评估完成!评分:{{score}}/5', {
|
||||
score: result.data.score
|
||||
})
|
||||
);
|
||||
|
||||
// 调用回调函数通知评估完成(通常用于刷新数据列表)
|
||||
if (onEvaluationComplete) {
|
||||
await onEvaluationComplete();
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || t('datasets.evaluateFailed', '评估失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('评估失败:', error);
|
||||
toast.error(
|
||||
t('datasets.evaluateError', '评估失败: {{error}}', {
|
||||
error: error.message
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
// 从评估中的ID列表移除
|
||||
setEvaluatingIds(prev => prev.filter(id => id !== dataset.id));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理批量评估
|
||||
*/
|
||||
const handleBatchEvaluate = async () => {
|
||||
// 检查模型配置
|
||||
if (!checkModelConfiguration()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBatchEvaluating(true);
|
||||
|
||||
// 调用批量评估接口
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/batch-evaluate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
language: 'zh-CN'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t('datasets.batchEvaluateStarted', '批量评估任务已启动,将在后台进行处理'));
|
||||
// 跳转到任务页面查看进度
|
||||
router.push(`/projects/${projectId}/tasks`);
|
||||
} else {
|
||||
toast.error(result.message || t('datasets.batchEvaluateStartFailed', '启动批量评估失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量评估失败:', error);
|
||||
toast.error(
|
||||
t('datasets.batchEvaluateFailed', '批量评估失败: {{error}}', {
|
||||
error: error.message
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setBatchEvaluating(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查指定数据集是否正在评估中
|
||||
* @param {string} datasetId - 数据集ID
|
||||
* @returns {boolean} 是否正在评估中
|
||||
*/
|
||||
const isEvaluating = datasetId => {
|
||||
return evaluatingIds.includes(datasetId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前正在评估的数据集数量
|
||||
* @returns {number} 正在评估的数据集数量
|
||||
*/
|
||||
const getEvaluatingCount = () => {
|
||||
return evaluatingIds.length;
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
evaluatingIds,
|
||||
batchEvaluating,
|
||||
|
||||
// 方法
|
||||
handleEvaluateDataset,
|
||||
handleBatchEvaluate,
|
||||
|
||||
// 工具方法
|
||||
isEvaluating,
|
||||
getEvaluatingCount,
|
||||
|
||||
// 模型信息(便于组件使用)
|
||||
model
|
||||
};
|
||||
};
|
||||
|
||||
export default useDatasetEvaluation;
|
||||
@@ -0,0 +1,487 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
const useDatasetExport = projectId => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 优化的流式导出 - 使用 WritableStream 避免内存溢出
|
||||
const exportDatasetsStreaming = async (exportOptions, onProgress) => {
|
||||
try {
|
||||
const batchSize = exportOptions.batchSize || 1000;
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
let totalProcessed = 0;
|
||||
let isFirstBatch = true;
|
||||
|
||||
// 确定文件格式
|
||||
const fileFormat = exportOptions.fileFormat || 'json';
|
||||
const formatType = exportOptions.formatType || 'alpaca';
|
||||
|
||||
// 生成文件名
|
||||
const formatSuffixMap = {
|
||||
alpaca: 'alpaca',
|
||||
multilingualthinking: 'multilingual-thinking',
|
||||
sharegpt: 'sharegpt',
|
||||
custom: 'custom'
|
||||
};
|
||||
const formatSuffix = formatSuffixMap[formatType] || formatType || 'export';
|
||||
const balanceSuffix = exportOptions.balanceMode ? '-balanced' : '';
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const fileName = `datasets-${projectId}-${formatSuffix}${balanceSuffix}-${dateStr}.${fileFormat}`;
|
||||
|
||||
// 创建可写流
|
||||
let fileStream;
|
||||
let writer;
|
||||
|
||||
try {
|
||||
// 使用 showSaveFilePicker API(现代浏览器)
|
||||
if (window.showSaveFilePicker) {
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: fileName,
|
||||
types: [
|
||||
{
|
||||
description: 'Dataset File',
|
||||
accept: {
|
||||
'application/json': [`.${fileFormat}`]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
fileStream = await handle.createWritable();
|
||||
} else {
|
||||
// 降级方案:使用内存缓冲区(但分块处理)
|
||||
fileStream = null;
|
||||
}
|
||||
} catch (err) {
|
||||
// 用户取消或不支持,使用降级方案
|
||||
fileStream = null;
|
||||
}
|
||||
|
||||
// 如果不支持流式写入,使用分块累积方案
|
||||
let chunks = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_CHUNKS_IN_MEMORY = 5; // 最多在内存中保留5批数据
|
||||
|
||||
// 写入文件头(JSON数组开始或CSV表头)
|
||||
if (fileFormat === 'json') {
|
||||
if (fileStream) {
|
||||
await fileStream.write('[\n');
|
||||
} else {
|
||||
chunks.push('[\n');
|
||||
}
|
||||
} else if (fileFormat === 'csv') {
|
||||
// 写入CSV表头
|
||||
const headers = getCSVHeaders(formatType, exportOptions);
|
||||
const headerLine = headers.join(',') + '\n';
|
||||
if (fileStream) {
|
||||
await fileStream.write(headerLine);
|
||||
} else {
|
||||
chunks.push(headerLine);
|
||||
}
|
||||
}
|
||||
|
||||
// 分批获取和写入数据
|
||||
while (hasMore) {
|
||||
const apiUrl = `/api/projects/${projectId}/datasets/export`;
|
||||
const requestBody = {
|
||||
batchMode: true,
|
||||
offset: offset,
|
||||
batchSize: batchSize
|
||||
};
|
||||
|
||||
// 如果有选中的数据集 ID,传递 ID 列表
|
||||
if (exportOptions.selectedIds && exportOptions.selectedIds.length > 0) {
|
||||
requestBody.selectedIds = exportOptions.selectedIds;
|
||||
} else if (exportOptions.confirmedOnly) {
|
||||
requestBody.status = 'confirmed';
|
||||
}
|
||||
|
||||
// 检查是否是平衡导出模式
|
||||
if (exportOptions.balanceMode && exportOptions.balanceConfig) {
|
||||
requestBody.balanceMode = true;
|
||||
requestBody.balanceConfig = exportOptions.balanceConfig;
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, requestBody);
|
||||
const batchResult = response.data;
|
||||
|
||||
// 如果需要包含文本块内容,批量查询并填充
|
||||
if (exportOptions.customFields?.includeChunk && batchResult.data.length > 0) {
|
||||
const chunkNames = batchResult.data.map(item => item.chunkName).filter(name => name);
|
||||
|
||||
if (chunkNames.length > 0) {
|
||||
try {
|
||||
const chunkResponse = await axios.post(`/api/projects/${projectId}/chunks/batch-content`, {
|
||||
chunkNames
|
||||
});
|
||||
const chunkContentMap = chunkResponse.data;
|
||||
|
||||
batchResult.data.forEach(item => {
|
||||
if (item.chunkName && chunkContentMap[item.chunkName]) {
|
||||
item.chunkContent = chunkContentMap[item.chunkName];
|
||||
}
|
||||
});
|
||||
} catch (chunkError) {
|
||||
console.error('获取文本块内容失败:', chunkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换当前批次数据
|
||||
const formattedBatch = formatDataBatch(batchResult.data, exportOptions);
|
||||
|
||||
// 写入当前批次
|
||||
if (fileFormat === 'json') {
|
||||
// 保持与原逻辑一致:JSON 导出为“格式化后的 JSON 数组”(2空格缩进)
|
||||
// 每条记录单独 stringify + 缩进,并在数组级别拼接,避免一次性 stringify 全量数据导致内存暴涨
|
||||
const batchContent = formattedBatch
|
||||
.map(item => {
|
||||
const pretty = JSON.stringify(item, null, 2);
|
||||
// 将对象的每一行整体再缩进 2 个空格,以符合数组元素缩进
|
||||
return ' ' + pretty.replace(/\n/g, '\n ');
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
const content = isFirstBatch ? batchContent : ',\n' + batchContent;
|
||||
|
||||
if (fileStream) {
|
||||
await fileStream.write(content);
|
||||
} else {
|
||||
chunks.push(content);
|
||||
chunkCount++;
|
||||
}
|
||||
} else if (fileFormat === 'jsonl') {
|
||||
const batchContent = formattedBatch.map(item => JSON.stringify(item)).join('\n') + '\n';
|
||||
|
||||
if (fileStream) {
|
||||
await fileStream.write(batchContent);
|
||||
} else {
|
||||
chunks.push(batchContent);
|
||||
chunkCount++;
|
||||
}
|
||||
} else if (fileFormat === 'csv') {
|
||||
const batchContent = formatBatchToCSV(formattedBatch, formatType, exportOptions);
|
||||
|
||||
if (fileStream) {
|
||||
await fileStream.write(batchContent);
|
||||
} else {
|
||||
chunks.push(batchContent);
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果使用内存缓冲且累积了足够多的块,触发部分下载
|
||||
if (!fileStream && chunkCount >= MAX_CHUNKS_IN_MEMORY) {
|
||||
// 这里我们仍然需要等到最后才能下载,但至少限制了内存使用
|
||||
// 可以考虑使用 Blob 分片
|
||||
}
|
||||
|
||||
hasMore = batchResult.hasMore;
|
||||
offset = batchResult.offset;
|
||||
totalProcessed += batchResult.data.length;
|
||||
isFirstBatch = false;
|
||||
|
||||
// 通知进度更新
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
processed: totalProcessed,
|
||||
currentBatch: batchResult.data.length,
|
||||
hasMore
|
||||
});
|
||||
}
|
||||
|
||||
// 避免过快请求
|
||||
if (hasMore) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
// 写入文件尾
|
||||
if (fileFormat === 'json') {
|
||||
if (fileStream) {
|
||||
await fileStream.write('\n]\n');
|
||||
await fileStream.close();
|
||||
} else {
|
||||
chunks.push('\n]\n');
|
||||
}
|
||||
} else {
|
||||
if (fileStream) {
|
||||
await fileStream.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果使用内存缓冲方案,现在触发下载
|
||||
if (!fileStream) {
|
||||
downloadFromChunks(chunks, fileName);
|
||||
}
|
||||
|
||||
toast.success(t('datasets.exportSuccess'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Streaming export failed:', error);
|
||||
toast.error(error.message || t('datasets.exportFailed'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 从内存块下载文件(优化版本,使用 Blob 流)
|
||||
const downloadFromChunks = (chunks, fileName) => {
|
||||
// 使用 Blob 构造函数,它会自动处理大数据
|
||||
const blob = new Blob(chunks, { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
// 延迟释放 URL,确保下载开始
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
// 获取CSV表头
|
||||
const getCSVHeaders = (formatType, exportOptions) => {
|
||||
if (formatType === 'alpaca') {
|
||||
return ['instruction', 'input', 'output', 'system'];
|
||||
} else if (formatType === 'sharegpt') {
|
||||
return ['messages'];
|
||||
} else if (formatType === 'multilingualthinking') {
|
||||
return ['reasoning_language', 'developer', 'user', 'analysis', 'final', 'messages'];
|
||||
} else if (formatType === 'custom') {
|
||||
const { questionField, answerField, cotField, includeLabels, includeChunk, questionOnly } =
|
||||
exportOptions.customFields;
|
||||
const headers = [questionField];
|
||||
if (!questionOnly) {
|
||||
headers.push(answerField);
|
||||
if (exportOptions.includeCOT && cotField) {
|
||||
headers.push(cotField);
|
||||
}
|
||||
}
|
||||
if (includeLabels) headers.push('label');
|
||||
if (includeChunk) headers.push('chunk');
|
||||
return headers;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// 格式化数据批次
|
||||
const formatDataBatch = (dataBatch, exportOptions) => {
|
||||
const formatType = exportOptions.formatType || 'alpaca';
|
||||
|
||||
if (formatType === 'alpaca') {
|
||||
if (exportOptions.alpacaFieldType === 'instruction') {
|
||||
return dataBatch.map(({ question, answer, cot }) => ({
|
||||
instruction: question,
|
||||
input: '',
|
||||
output: cot && exportOptions.includeCOT ? `<think>${cot}</think>\n${answer}` : answer,
|
||||
system: exportOptions.systemPrompt || ''
|
||||
}));
|
||||
} else {
|
||||
return dataBatch.map(({ question, answer, cot }) => ({
|
||||
instruction: exportOptions.customInstruction || '',
|
||||
input: question,
|
||||
output: cot && exportOptions.includeCOT ? `<think>${cot}</think>\n${answer}` : answer,
|
||||
system: exportOptions.systemPrompt || ''
|
||||
}));
|
||||
}
|
||||
} else if (formatType === 'sharegpt') {
|
||||
return dataBatch.map(({ question, answer, cot }) => {
|
||||
const messages = [];
|
||||
if (exportOptions.systemPrompt) {
|
||||
messages.push({ role: 'system', content: exportOptions.systemPrompt });
|
||||
}
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: question
|
||||
});
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: cot && exportOptions.includeCOT ? `<think>${cot}</think>\n${answer}` : answer
|
||||
});
|
||||
return { messages };
|
||||
});
|
||||
} else if (formatType === 'multilingualthinking') {
|
||||
return dataBatch.map(({ question, answer, cot }) => ({
|
||||
reasoning_language: exportOptions.reasoningLanguage || 'English',
|
||||
developer: exportOptions.systemPrompt || '',
|
||||
user: question,
|
||||
analysis: exportOptions.includeCOT && cot ? cot : null,
|
||||
final: answer,
|
||||
messages: [
|
||||
{
|
||||
content: exportOptions.systemPrompt || '',
|
||||
role: 'system',
|
||||
thinking: null
|
||||
},
|
||||
{
|
||||
content: question,
|
||||
role: 'user',
|
||||
thinking: null
|
||||
},
|
||||
{
|
||||
content: answer,
|
||||
role: 'assistant',
|
||||
thinking: exportOptions.includeCOT && cot ? cot : null
|
||||
}
|
||||
]
|
||||
}));
|
||||
} else if (formatType === 'custom') {
|
||||
const { questionField, answerField, cotField, includeLabels, includeChunk, questionOnly } =
|
||||
exportOptions.customFields;
|
||||
return dataBatch.map(({ question, answer, cot, questionLabel: labels, chunkContent }) => {
|
||||
const item = { [questionField]: question };
|
||||
if (!questionOnly) {
|
||||
item[answerField] = answer;
|
||||
if (cot && exportOptions.includeCOT && cotField) {
|
||||
item[cotField] = cot;
|
||||
}
|
||||
}
|
||||
if (includeLabels && labels && labels.length > 0) {
|
||||
item.label = labels.split(' ')[1];
|
||||
}
|
||||
if (includeChunk && chunkContent) {
|
||||
item.chunk = chunkContent;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
return dataBatch;
|
||||
};
|
||||
|
||||
// 将批次格式化为CSV行
|
||||
const formatBatchToCSV = (formattedBatch, formatType, exportOptions) => {
|
||||
const headers = getCSVHeaders(formatType, exportOptions);
|
||||
return (
|
||||
formattedBatch
|
||||
.map(item => {
|
||||
return headers
|
||||
.map(header => {
|
||||
let field = item[header]?.toString() || '';
|
||||
// 对于复杂对象,转换为JSON字符串
|
||||
if (typeof item[header] === 'object') {
|
||||
field = JSON.stringify(item[header]);
|
||||
}
|
||||
// CSV转义
|
||||
if (field.includes(',') || field.includes('\n') || field.includes('"')) {
|
||||
field = `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return field;
|
||||
})
|
||||
.join(',');
|
||||
})
|
||||
.join('\n') + '\n'
|
||||
);
|
||||
};
|
||||
|
||||
// 处理和下载数据的通用函数(保留用于小数据量)
|
||||
const processAndDownloadData = async (dataToExport, exportOptions) => {
|
||||
const formattedData = formatDataBatch(dataToExport, exportOptions);
|
||||
|
||||
let content;
|
||||
let fileExtension;
|
||||
const fileFormat = exportOptions.fileFormat || 'json';
|
||||
|
||||
if (fileFormat === 'jsonl') {
|
||||
content = formattedData.map(item => JSON.stringify(item)).join('\n');
|
||||
fileExtension = 'jsonl';
|
||||
} else if (fileFormat === 'csv') {
|
||||
const headers = getCSVHeaders(exportOptions.formatType, exportOptions);
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
...formattedData.map(item =>
|
||||
headers
|
||||
.map(header => {
|
||||
let field = item[header]?.toString() || '';
|
||||
if (typeof item[header] === 'object') {
|
||||
field = JSON.stringify(item[header]);
|
||||
}
|
||||
if (field.includes(',') || field.includes('\n') || field.includes('"')) {
|
||||
field = `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return field;
|
||||
})
|
||||
.join(',')
|
||||
)
|
||||
];
|
||||
content = csvRows.join('\n');
|
||||
fileExtension = 'csv';
|
||||
} else {
|
||||
content = JSON.stringify(formattedData, null, 2);
|
||||
fileExtension = 'json';
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
const formatSuffixMap = {
|
||||
alpaca: 'alpaca',
|
||||
multilingualthinking: 'multilingual-thinking',
|
||||
sharegpt: 'sharegpt',
|
||||
custom: 'custom'
|
||||
};
|
||||
const formatSuffix = formatSuffixMap[exportOptions.formatType] || exportOptions.formatType || 'export';
|
||||
const balanceSuffix = exportOptions.balanceMode ? '-balanced' : '';
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
a.download = `datasets-${projectId}-${formatSuffix}${balanceSuffix}-${dateStr}.${fileExtension}`;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 导出数据集(保持向后兼容的原有功能)
|
||||
const exportDatasets = async exportOptions => {
|
||||
try {
|
||||
const apiUrl = `/api/projects/${projectId}/datasets/export`;
|
||||
const requestBody = {};
|
||||
|
||||
if (exportOptions.selectedIds && exportOptions.selectedIds.length > 0) {
|
||||
requestBody.selectedIds = exportOptions.selectedIds;
|
||||
} else if (exportOptions.confirmedOnly) {
|
||||
requestBody.status = 'confirmed';
|
||||
}
|
||||
|
||||
if (exportOptions.balanceMode && exportOptions.balanceConfig) {
|
||||
requestBody.balanceMode = true;
|
||||
requestBody.balanceConfig = exportOptions.balanceConfig;
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, requestBody);
|
||||
let dataToExport = response.data;
|
||||
|
||||
await processAndDownloadData(dataToExport, exportOptions);
|
||||
|
||||
toast.success(t('datasets.exportSuccess'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 导出平衡数据集
|
||||
const exportBalancedDataset = async exportOptions => {
|
||||
const balancedOptions = {
|
||||
...exportOptions,
|
||||
balanceMode: true,
|
||||
balanceConfig: exportOptions.balanceConfig
|
||||
};
|
||||
return await exportDatasets(balancedOptions);
|
||||
};
|
||||
|
||||
return {
|
||||
exportDatasets,
|
||||
exportBalancedDataset,
|
||||
exportDatasetsStreaming
|
||||
};
|
||||
};
|
||||
|
||||
export default useDatasetExport;
|
||||
export { useDatasetExport };
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 数据集筛选条件持久化 Hook
|
||||
* 负责筛选条件的保存、恢复和管理
|
||||
* @param {string} projectId - 项目ID
|
||||
* @returns {Object} 筛选条件和相关方法
|
||||
*/
|
||||
export function useDatasetFilters(projectId) {
|
||||
const [filterConfirmed, setFilterConfirmed] = useState('all');
|
||||
const [filterHasCot, setFilterHasCot] = useState('all');
|
||||
const [filterIsDistill, setFilterIsDistill] = useState('all');
|
||||
const [filterScoreRange, setFilterScoreRange] = useState([0, 5]);
|
||||
const [filterCustomTag, setFilterCustomTag] = useState('');
|
||||
const [filterNoteKeyword, setFilterNoteKeyword] = useState('');
|
||||
const [filterChunkName, setFilterChunkName] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchField, setSearchField] = useState('question');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// 从 localStorage 恢复筛选条件
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const savedFilters = localStorage.getItem(`datasets-filters-${projectId}`);
|
||||
if (savedFilters) {
|
||||
const filters = JSON.parse(savedFilters);
|
||||
setFilterConfirmed(filters.filterConfirmed || 'all');
|
||||
setFilterHasCot(filters.filterHasCot || 'all');
|
||||
setFilterIsDistill(filters.filterIsDistill || 'all');
|
||||
setFilterScoreRange(filters.filterScoreRange || [0, 5]);
|
||||
setFilterCustomTag(filters.filterCustomTag || '');
|
||||
setFilterNoteKeyword(filters.filterNoteKeyword || '');
|
||||
setFilterChunkName(filters.filterChunkName || '');
|
||||
setSearchQuery(filters.searchQuery || '');
|
||||
setSearchField(filters.searchField || 'question');
|
||||
setPage(filters.page || 1);
|
||||
setRowsPerPage(filters.rowsPerPage || 10);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复筛选条件失败:', error);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 保存筛选条件到 localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && isInitialized) {
|
||||
try {
|
||||
const filters = {
|
||||
filterConfirmed,
|
||||
filterHasCot,
|
||||
filterIsDistill,
|
||||
filterScoreRange,
|
||||
filterCustomTag,
|
||||
filterNoteKeyword,
|
||||
filterChunkName,
|
||||
searchQuery,
|
||||
searchField,
|
||||
page,
|
||||
rowsPerPage
|
||||
};
|
||||
localStorage.setItem(`datasets-filters-${projectId}`, JSON.stringify(filters));
|
||||
} catch (error) {
|
||||
console.error('保存筛选条件失败:', error);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
projectId,
|
||||
filterConfirmed,
|
||||
filterHasCot,
|
||||
filterIsDistill,
|
||||
filterScoreRange,
|
||||
filterCustomTag,
|
||||
filterNoteKeyword,
|
||||
filterChunkName,
|
||||
searchQuery,
|
||||
searchField,
|
||||
page,
|
||||
rowsPerPage,
|
||||
isInitialized
|
||||
]);
|
||||
|
||||
/**
|
||||
* 重置所有筛选条件为默认值
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
setFilterConfirmed('all');
|
||||
setFilterHasCot('all');
|
||||
setFilterIsDistill('all');
|
||||
setFilterScoreRange([0, 5]);
|
||||
setFilterCustomTag('');
|
||||
setFilterNoteKeyword('');
|
||||
setFilterChunkName('');
|
||||
setSearchQuery('');
|
||||
setSearchField('question');
|
||||
setPage(1);
|
||||
setRowsPerPage(10);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除 localStorage 中的筛选条件
|
||||
*/
|
||||
const clearSavedFilters = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(`datasets-filters-${projectId}`);
|
||||
} catch (error) {
|
||||
console.error('清除筛选条件失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算当前活跃的筛选条件数量
|
||||
* @returns {number} 活跃筛选条件的数量
|
||||
*/
|
||||
const getActiveFilterCount = () => {
|
||||
let count = 0;
|
||||
|
||||
if (filterConfirmed !== 'all') count++;
|
||||
if (filterHasCot !== 'all') count++;
|
||||
if (filterIsDistill !== 'all') count++;
|
||||
if (filterScoreRange[0] > 0 || filterScoreRange[1] < 5) count++;
|
||||
if (filterCustomTag) count++;
|
||||
if (filterNoteKeyword) count++;
|
||||
if (filterChunkName) count++;
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
return {
|
||||
// 筛选条件状态
|
||||
filterConfirmed,
|
||||
setFilterConfirmed,
|
||||
filterHasCot,
|
||||
setFilterHasCot,
|
||||
filterIsDistill,
|
||||
setFilterIsDistill,
|
||||
filterScoreRange,
|
||||
setFilterScoreRange,
|
||||
filterCustomTag,
|
||||
setFilterCustomTag,
|
||||
filterNoteKeyword,
|
||||
setFilterNoteKeyword,
|
||||
filterChunkName,
|
||||
setFilterChunkName,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchField,
|
||||
setSearchField,
|
||||
// 分页状态
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
// 初始化状态
|
||||
isInitialized,
|
||||
// 工具方法
|
||||
resetFilters,
|
||||
clearSavedFilters,
|
||||
getActiveFilterCount
|
||||
};
|
||||
}
|
||||
|
||||
export default useDatasetFilters;
|
||||
596
easy-dataset-main/app/projects/[projectId]/datasets/page.js
Normal file
596
easy-dataset-main/app/projects/[projectId]/datasets/page.js
Normal file
@@ -0,0 +1,596 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Container, Box, Typography, Button, Card, useTheme, alpha } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ExportDatasetDialog from '@/components/ExportDatasetDialog';
|
||||
import ExportProgressDialog from '@/components/ExportProgressDialog';
|
||||
import ImportDatasetDialog from '@/components/datasets/ImportDatasetDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DatasetList from './components/DatasetList';
|
||||
import SearchBar from './components/SearchBar';
|
||||
import ActionBar from './components/ActionBar';
|
||||
import FilterDialog from './components/FilterDialog';
|
||||
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
|
||||
import useDatasetExport from './hooks/useDatasetExport';
|
||||
import useDatasetEvaluation from './hooks/useDatasetEvaluation';
|
||||
import useDatasetFilters from './hooks/useDatasetFilters';
|
||||
import { processInParallel } from '@/lib/util/async';
|
||||
import axios from 'axios';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 主页面组件
|
||||
export default function DatasetsPage({ params }) {
|
||||
const { projectId } = params;
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const [datasets, setDatasets] = useState({ data: [], total: 0, confirmedCount: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteDialog, setDeleteDialog] = useState({
|
||||
open: false,
|
||||
datasets: null,
|
||||
batch: false,
|
||||
deleting: false
|
||||
});
|
||||
const [exportDialog, setExportDialog] = useState({ open: false });
|
||||
const [importDialog, setImportDialog] = useState({ open: false });
|
||||
const [selectedIds, setselectedIds] = useState([]);
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 使用 useDatasetFilters Hook 管理筛选条件
|
||||
const {
|
||||
filterConfirmed,
|
||||
setFilterConfirmed,
|
||||
filterHasCot,
|
||||
setFilterHasCot,
|
||||
filterIsDistill,
|
||||
setFilterIsDistill,
|
||||
filterScoreRange,
|
||||
setFilterScoreRange,
|
||||
filterCustomTag,
|
||||
setFilterCustomTag,
|
||||
filterNoteKeyword,
|
||||
setFilterNoteKeyword,
|
||||
filterChunkName,
|
||||
setFilterChunkName,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchField,
|
||||
setSearchField,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
isInitialized,
|
||||
getActiveFilterCount
|
||||
} = useDatasetFilters(projectId);
|
||||
|
||||
const debouncedSearchQuery = useDebounce(searchQuery);
|
||||
// 删除进度状态
|
||||
const [deleteProgress, setDeteleProgress] = useState({
|
||||
total: 0, // 总删除问题数量
|
||||
completed: 0, // 已删除完成的数量
|
||||
percentage: 0 // 进度百分比
|
||||
});
|
||||
// 导出进度状态
|
||||
const [exportProgress, setExportProgress] = useState({
|
||||
show: false, // 是否显示进度
|
||||
processed: 0, // 已处理数量
|
||||
total: 0, // 总数量
|
||||
hasMore: true // 是否还有更多数据
|
||||
});
|
||||
|
||||
// 3. 添加打开导出对话框的处理函数
|
||||
const handleOpenExportDialog = () => {
|
||||
setExportDialog({ open: true });
|
||||
};
|
||||
|
||||
// 4. 添加关闭导出对话框的处理函数
|
||||
const handleCloseExportDialog = () => {
|
||||
setExportDialog({ open: false });
|
||||
};
|
||||
|
||||
// 5. 添加打开导入对话框的处理函数
|
||||
const handleOpenImportDialog = () => {
|
||||
setImportDialog({ open: true });
|
||||
};
|
||||
|
||||
// 6. 添加关闭导入对话框的处理函数
|
||||
const handleCloseImportDialog = () => {
|
||||
setImportDialog({ open: false });
|
||||
};
|
||||
|
||||
// 7. 导入成功后的处理函数
|
||||
const handleImportSuccess = () => {
|
||||
// 刷新数据集列表
|
||||
getDatasetsList();
|
||||
toast.success(t('import.importSuccess', '数据集导入成功'));
|
||||
};
|
||||
|
||||
// 获取数据集列表
|
||||
const getDatasetsList = useCallback(
|
||||
async ({ pageOverride } = {}) => {
|
||||
const effectivePage = pageOverride ?? page;
|
||||
try {
|
||||
setLoading(true);
|
||||
let url = `/api/projects/${projectId}/datasets?page=${effectivePage}&size=${rowsPerPage}`;
|
||||
|
||||
if (filterConfirmed !== 'all') {
|
||||
url += `&status=${filterConfirmed}`;
|
||||
}
|
||||
|
||||
if (debouncedSearchQuery) {
|
||||
url += `&input=${encodeURIComponent(debouncedSearchQuery)}&field=${searchField}`;
|
||||
}
|
||||
|
||||
if (filterHasCot !== 'all') {
|
||||
url += `&hasCot=${filterHasCot}`;
|
||||
}
|
||||
|
||||
if (filterIsDistill !== 'all') {
|
||||
url += `&isDistill=${filterIsDistill}`;
|
||||
}
|
||||
|
||||
if (filterScoreRange[0] > 0 || filterScoreRange[1] < 5) {
|
||||
url += `&scoreRange=${filterScoreRange[0]}-${filterScoreRange[1]}`;
|
||||
}
|
||||
|
||||
if (filterCustomTag) {
|
||||
url += `&customTag=${encodeURIComponent(filterCustomTag)}`;
|
||||
}
|
||||
|
||||
if (filterNoteKeyword) {
|
||||
url += `¬eKeyword=${encodeURIComponent(filterNoteKeyword)}`;
|
||||
}
|
||||
|
||||
if (filterChunkName) {
|
||||
url += `&chunkName=${encodeURIComponent(filterChunkName)}`;
|
||||
}
|
||||
|
||||
const response = await axios.get(url);
|
||||
setDatasets(response.data || { data: [], total: 0, confirmedCount: 0 });
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
debouncedSearchQuery,
|
||||
filterConfirmed,
|
||||
filterCustomTag,
|
||||
filterHasCot,
|
||||
filterIsDistill,
|
||||
filterNoteKeyword,
|
||||
filterChunkName,
|
||||
filterScoreRange,
|
||||
page,
|
||||
projectId,
|
||||
rowsPerPage,
|
||||
searchField
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
getDatasetsList();
|
||||
// 获取项目中所有使用过的标签
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/tags`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableTags(data.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error);
|
||||
}
|
||||
};
|
||||
fetchAvailableTags();
|
||||
}, [projectId, page, rowsPerPage, debouncedSearchQuery, searchField, isInitialized]);
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (_event, newPage) => {
|
||||
// MUI TablePagination 的页码从 0 开始,而我们的 API 从 1 开始
|
||||
setPage(newPage + 1);
|
||||
};
|
||||
|
||||
// 处理每页行数变化
|
||||
const handleRowsPerPageChange = event => {
|
||||
setPage(1);
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
};
|
||||
|
||||
// 打开删除确认框
|
||||
const handleOpenDeleteDialog = dataset => {
|
||||
setDeleteDialog({
|
||||
open: true,
|
||||
datasets: [dataset]
|
||||
});
|
||||
};
|
||||
|
||||
// 关闭删除确认框
|
||||
const handleCloseDeleteDialog = () => {
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
dataset: null
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDeleteDataset = async () => {
|
||||
const datasetsArray = selectedIds.map(id => ({ id }));
|
||||
setDeleteDialog({
|
||||
open: true,
|
||||
datasets: datasetsArray,
|
||||
batch: true,
|
||||
count: selectedIds.length
|
||||
});
|
||||
};
|
||||
|
||||
const resetProgress = () => {
|
||||
setDeteleProgress({
|
||||
total: deleteDialog.count,
|
||||
completed: 0,
|
||||
percentage: 0
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deleteDialog.batch) {
|
||||
setDeleteDialog({
|
||||
...deleteDialog,
|
||||
deleting: true
|
||||
});
|
||||
await handleBatchDelete();
|
||||
resetProgress();
|
||||
} else {
|
||||
const [dataset] = deleteDialog.datasets;
|
||||
if (!dataset) return;
|
||||
await handleDelete(dataset);
|
||||
}
|
||||
setselectedIds([]);
|
||||
// 刷新数据
|
||||
getDatasetsList();
|
||||
// 关闭确认框
|
||||
handleCloseDeleteDialog();
|
||||
};
|
||||
|
||||
// 批量删除数据集
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
await processInParallel(
|
||||
selectedIds,
|
||||
async datasetId => {
|
||||
await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
3,
|
||||
(cur, total) => {
|
||||
setDeteleProgress({
|
||||
total,
|
||||
completed: cur,
|
||||
percentage: Math.floor((cur / total) * 100)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
toast.success(t('common.deleteSuccess'));
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error);
|
||||
toast.error(error.message || t('common.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据集
|
||||
const handleDelete = async dataset => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets?id=${dataset.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error(t('datasets.deleteFailed'));
|
||||
|
||||
toast.success(t('datasets.deleteSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(error.message || t('datasets.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 使用自定义 Hook 处理数据集导出逻辑
|
||||
const { exportDatasets, exportDatasetsStreaming } = useDatasetExport(projectId);
|
||||
|
||||
// 使用自定义 Hook 处理数据集评估逻辑
|
||||
const { evaluatingIds, batchEvaluating, handleEvaluateDataset, handleBatchEvaluate } = useDatasetEvaluation(
|
||||
projectId,
|
||||
getDatasetsList
|
||||
);
|
||||
|
||||
// 处理导出数据集 - 智能选择导出方式
|
||||
const handleExportDatasets = async exportOptions => {
|
||||
try {
|
||||
// 如果是平衡导出,则忽略选中项,按 balanceConfig 导出
|
||||
const exportOptionsWithSelection = exportOptions.balanceMode
|
||||
? { ...exportOptions }
|
||||
: { ...exportOptions, ...(selectedIds.length > 0 && { selectedIds }) };
|
||||
|
||||
// 获取数据总量:
|
||||
// 平衡导出时,按 balanceConfig 的总量计算;
|
||||
// 其他情况:如果有选中数据集则使用选中数量,否则使用当前筛选条件下的数据总量
|
||||
const balancedTotal = Array.isArray(exportOptions.balanceConfig)
|
||||
? exportOptions.balanceConfig.reduce((sum, c) => sum + (parseInt(c.maxCount) || 0), 0)
|
||||
: 0;
|
||||
const totalCount = exportOptions.balanceMode
|
||||
? balancedTotal
|
||||
: selectedIds.length > 0
|
||||
? selectedIds.length
|
||||
: datasets.total || 0;
|
||||
|
||||
// 设置阈值:超过1000条数据使用流式导出
|
||||
const STREAMING_THRESHOLD = 1000;
|
||||
|
||||
// 检查是否需要包含文本块内容
|
||||
const needsChunkContent = exportOptions.formatType === 'custom' && exportOptions.customFields?.includeChunk;
|
||||
|
||||
let success = false;
|
||||
|
||||
// 如果数据量大于阈值或需要查询文本块内容,使用流式导出
|
||||
if (totalCount > STREAMING_THRESHOLD || needsChunkContent) {
|
||||
// 使用流式导出,显示进度
|
||||
setExportProgress({ show: true, processed: 0, total: totalCount });
|
||||
|
||||
success = await exportDatasetsStreaming(exportOptionsWithSelection, progress => {
|
||||
setExportProgress(prev => ({
|
||||
...prev,
|
||||
processed: progress.processed,
|
||||
hasMore: progress.hasMore
|
||||
}));
|
||||
});
|
||||
|
||||
// 隐藏进度
|
||||
setExportProgress({ show: false, processed: 0, total: 0 });
|
||||
} else {
|
||||
// 使用传统导出方式
|
||||
success = await exportDatasets(exportOptionsWithSelection);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// 关闭export对话框
|
||||
handleCloseExportDialog();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportProgress({ show: false, processed: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetails = id => {
|
||||
router.push(`/projects/${projectId}/datasets/${id}`);
|
||||
};
|
||||
|
||||
// 处理全选/取消全选
|
||||
const handleSelectAll = async event => {
|
||||
if (event.target.checked) {
|
||||
// 获取所有符合当前筛选条件的数据,不受分页限制
|
||||
let url = `/api/projects/${projectId}/datasets?selectedAll=1`;
|
||||
|
||||
if (filterConfirmed !== 'all') {
|
||||
url += `&status=${filterConfirmed}`;
|
||||
}
|
||||
|
||||
if (debouncedSearchQuery) {
|
||||
url += `&input=${encodeURIComponent(debouncedSearchQuery)}&field=${searchField}`;
|
||||
}
|
||||
|
||||
if (filterHasCot !== 'all') {
|
||||
url += `&hasCot=${filterHasCot}`;
|
||||
}
|
||||
|
||||
if (filterIsDistill !== 'all') {
|
||||
url += `&isDistill=${filterIsDistill}`;
|
||||
}
|
||||
|
||||
if (filterScoreRange[0] > 0 || filterScoreRange[1] < 5) {
|
||||
url += `&scoreRange=${filterScoreRange[0]}-${filterScoreRange[1]}`;
|
||||
}
|
||||
|
||||
if (filterCustomTag) {
|
||||
url += `&customTag=${encodeURIComponent(filterCustomTag)}`;
|
||||
}
|
||||
|
||||
if (filterNoteKeyword) {
|
||||
url += `¬eKeyword=${encodeURIComponent(filterNoteKeyword)}`;
|
||||
}
|
||||
|
||||
const response = await axios.get(url);
|
||||
setselectedIds(response.data.map(dataset => dataset.id));
|
||||
} else {
|
||||
setselectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单个选择
|
||||
const handleSelectItem = id => {
|
||||
setselectedIds(prev => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter(item => item !== id);
|
||||
} else {
|
||||
return [...prev, id];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setFilterConfirmed('all');
|
||||
setFilterHasCot('all');
|
||||
setFilterIsDistill('all');
|
||||
setFilterScoreRange([0, 5]);
|
||||
setFilterCustomTag('');
|
||||
setFilterNoteKeyword('');
|
||||
setFilterChunkName('');
|
||||
setPage(1);
|
||||
getDatasetsList({ pageOverride: 1 });
|
||||
}, [
|
||||
getDatasetsList,
|
||||
setFilterConfirmed,
|
||||
setFilterHasCot,
|
||||
setFilterIsDistill,
|
||||
setFilterScoreRange,
|
||||
setFilterCustomTag,
|
||||
setFilterNoteKeyword,
|
||||
setFilterChunkName,
|
||||
setPage
|
||||
]);
|
||||
|
||||
const handleApplyFilters = useCallback(() => {
|
||||
setFilterDialogOpen(false);
|
||||
setPage(1);
|
||||
getDatasetsList({ pageOverride: 1 });
|
||||
}, [getDatasetsList, setFilterDialogOpen, setPage]);
|
||||
const handleCloseFilterDialog = useCallback(() => setFilterDialogOpen(false), [setFilterDialogOpen]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 6 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 4,
|
||||
p: 3,
|
||||
backgroundColor: alpha(theme.palette.primary.light, 0.05),
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<SearchBar
|
||||
searchQuery={searchQuery}
|
||||
searchField={searchField}
|
||||
onSearchQueryChange={value => {
|
||||
setSearchQuery(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onSearchFieldChange={value => {
|
||||
setSearchField(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onMoreFiltersClick={() => setFilterDialogOpen(true)}
|
||||
activeFilterCount={getActiveFilterCount()}
|
||||
/>
|
||||
<ActionBar
|
||||
batchEvaluating={batchEvaluating}
|
||||
onBatchEvaluate={handleBatchEvaluate}
|
||||
onImport={handleOpenImportDialog}
|
||||
onExport={handleOpenExportDialog}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
{selectedIds.length ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: '10px',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('datasets.selected', {
|
||||
count: selectedIds.length
|
||||
})}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
sx={{ borderRadius: 2 }}
|
||||
onClick={handleBatchDeleteDataset}
|
||||
>
|
||||
{t('datasets.batchDelete')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<DatasetList
|
||||
datasets={datasets.data || []}
|
||||
onViewDetails={handleViewDetails}
|
||||
onDelete={handleOpenDeleteDialog}
|
||||
onEvaluate={handleEvaluateDataset}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
total={datasets.total || 0}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAll={handleSelectAll}
|
||||
onSelectItem={handleSelectItem}
|
||||
evaluatingIds={evaluatingIds}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialog.open}
|
||||
datasets={deleteDialog.datasets || []}
|
||||
onClose={handleCloseDeleteDialog}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
batch={deleteDialog.batch}
|
||||
progress={deleteProgress}
|
||||
deleting={deleteDialog.deleting}
|
||||
/>
|
||||
|
||||
<FilterDialog
|
||||
open={filterDialogOpen}
|
||||
onClose={handleCloseFilterDialog}
|
||||
filterConfirmed={filterConfirmed}
|
||||
filterHasCot={filterHasCot}
|
||||
filterIsDistill={filterIsDistill}
|
||||
filterScoreRange={filterScoreRange}
|
||||
filterCustomTag={filterCustomTag}
|
||||
filterNoteKeyword={filterNoteKeyword}
|
||||
filterChunkName={filterChunkName}
|
||||
availableTags={availableTags}
|
||||
onFilterConfirmedChange={setFilterConfirmed}
|
||||
onFilterHasCotChange={setFilterHasCot}
|
||||
onFilterIsDistillChange={setFilterIsDistill}
|
||||
onFilterScoreRangeChange={setFilterScoreRange}
|
||||
onFilterCustomTagChange={setFilterCustomTag}
|
||||
onFilterNoteKeywordChange={setFilterNoteKeyword}
|
||||
onFilterChunkNameChange={setFilterChunkName}
|
||||
onResetFilters={handleResetFilters}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
/>
|
||||
|
||||
<ExportDatasetDialog
|
||||
open={exportDialog.open}
|
||||
onClose={handleCloseExportDialog}
|
||||
onExport={handleExportDatasets}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<ImportDatasetDialog
|
||||
open={importDialog.open}
|
||||
onClose={handleCloseImportDialog}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
{/* 导出进度对话框 */}
|
||||
<ExportProgressDialog open={exportProgress.show} progress={exportProgress} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
'use client';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* 自动蒸馏服务
|
||||
*/
|
||||
class AutoDistillService {
|
||||
/**
|
||||
* 执行自动蒸馆任务
|
||||
* @param {Object} config - 配置信息
|
||||
* @param {string} config.projectId - 项目ID
|
||||
* @param {string} config.topic - 蒸馆主题
|
||||
* @param {number} config.levels - 标签层级
|
||||
* @param {number} config.tagsPerLevel - 每层标签数量
|
||||
* @param {number} config.questionsPerTag - 每个标签问题数量
|
||||
* @param {Object} config.model - 模型信息
|
||||
* @param {string} config.language - 语言
|
||||
* @param {Function} config.onProgress - 进度回调
|
||||
* @param {Function} config.onLog - 日志回调
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async executeDistillTask(config) {
|
||||
const {
|
||||
projectId,
|
||||
topic,
|
||||
levels,
|
||||
tagsPerLevel,
|
||||
questionsPerTag,
|
||||
model,
|
||||
language,
|
||||
datasetType = 'single-turn', // 新增数据集类型
|
||||
concurrencyLimit = 5,
|
||||
onProgress,
|
||||
onLog
|
||||
} = config;
|
||||
|
||||
// 项目名称存储,用于整个流程共享
|
||||
this.projectName = '';
|
||||
|
||||
try {
|
||||
// 初始化进度信息
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'initializing',
|
||||
tagsTotal: 0,
|
||||
tagsBuilt: 0,
|
||||
questionsTotal: 0,
|
||||
questionsBuilt: 0,
|
||||
datasetsTotal: 0,
|
||||
datasetsBuilt: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 获取项目名称,只需获取一次
|
||||
try {
|
||||
const projectResponse = await axios.get(`/api/projects/${projectId}`);
|
||||
if (projectResponse && projectResponse.data && projectResponse.data.name) {
|
||||
this.projectName = projectResponse.data.name;
|
||||
this.addLog(onLog, `Using project name "${this.projectName}" as the top-level tag`);
|
||||
} else {
|
||||
this.projectName = topic; // 如果无法获取项目名称,则使用主题作为默认值
|
||||
this.addLog(onLog, `Could not find project name, using topic "${topic}" as the top-level tag`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.projectName = topic; // 出错时使用主题作为默认值
|
||||
this.addLog(onLog, `Failed to get project name, using topic "${topic}" instead: ${error.message}`);
|
||||
}
|
||||
|
||||
// 添加日志
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Starting to build tag tree for "${topic}", number of levels: ${levels}, tags per level: ${tagsPerLevel}, questions per tag: ${questionsPerTag}`
|
||||
);
|
||||
|
||||
// 从根节点开始构建标签树
|
||||
await this.buildTagTree({
|
||||
projectId,
|
||||
topic,
|
||||
levels,
|
||||
tagsPerLevel,
|
||||
model,
|
||||
language,
|
||||
onProgress,
|
||||
onLog
|
||||
});
|
||||
|
||||
// 所有标签构建完成后,生成问题
|
||||
await this.generateQuestionsForTags({
|
||||
projectId,
|
||||
levels,
|
||||
questionsPerTag,
|
||||
model,
|
||||
language,
|
||||
concurrencyLimit,
|
||||
onProgress,
|
||||
onLog
|
||||
});
|
||||
|
||||
// 根据数据集类型生成不同类型的数据集
|
||||
if (datasetType === 'single-turn') {
|
||||
// 只生成单轮对话数据集
|
||||
await this.generateDatasetsForQuestions({
|
||||
projectId,
|
||||
model,
|
||||
language,
|
||||
concurrencyLimit,
|
||||
onProgress,
|
||||
onLog
|
||||
});
|
||||
} else if (datasetType === 'multi-turn') {
|
||||
// 只生成多轮对话数据集
|
||||
await this.generateMultiTurnDatasetsForQuestions({
|
||||
projectId,
|
||||
model,
|
||||
language,
|
||||
concurrencyLimit,
|
||||
onProgress,
|
||||
onLog
|
||||
});
|
||||
} else if (datasetType === 'both') {
|
||||
// 先生成单轮对话数据集
|
||||
await this.generateDatasetsForQuestions({
|
||||
projectId,
|
||||
model,
|
||||
language,
|
||||
concurrencyLimit,
|
||||
onProgress,
|
||||
onLog
|
||||
});
|
||||
// 再生成多轮对话数据集
|
||||
await this.generateMultiTurnDatasetsForQuestions({
|
||||
projectId,
|
||||
model,
|
||||
language,
|
||||
concurrencyLimit,
|
||||
onProgress,
|
||||
onLog
|
||||
});
|
||||
}
|
||||
|
||||
// 任务完成
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'completed'
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(onLog, 'Auto distillation task completed');
|
||||
} catch (error) {
|
||||
console.error('自动蒸馏任务执行失败:', error);
|
||||
this.addLog(onLog, `Task execution error: ${error.message || 'Unknown error'}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建标签树
|
||||
* @param {Object} config - 配置信息
|
||||
* @param {string} config.projectId - 项目ID
|
||||
* @param {string} config.topic - 蒸馆主题
|
||||
* @param {number} config.levels - 标签层级
|
||||
* @param {number} config.tagsPerLevel - 每层标签数量
|
||||
* @param {Object} config.model - 模型信息
|
||||
* @param {string} config.language - 语言
|
||||
* @param {Function} config.onProgress - 进度回调
|
||||
* @param {Function} config.onLog - 日志回调
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async buildTagTree(config) {
|
||||
const { projectId, topic, levels, tagsPerLevel, model, language, onProgress, onLog } = config;
|
||||
|
||||
// 使用已经获取的项目名称,如果未获取到,则使用主题
|
||||
const projectName = this.projectName || topic;
|
||||
|
||||
try {
|
||||
// 设置初始阶段
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'level1'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有现有标签
|
||||
let allTags = [];
|
||||
try {
|
||||
const response = await axios.get(`/api/projects/${projectId}/distill/tags/all`);
|
||||
allTags = response.data;
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error);
|
||||
this.addLog(onLog, `Failed to get tags: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取叶子节点总数,更新进度条
|
||||
const leafTags = Math.pow(tagsPerLevel, levels);
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
tagsTotal: leafTags
|
||||
});
|
||||
}
|
||||
|
||||
// 批量构建标签树
|
||||
await this.batchBuildTagTree({
|
||||
projectId,
|
||||
topic,
|
||||
levels,
|
||||
tagsPerLevel,
|
||||
model,
|
||||
language,
|
||||
projectName,
|
||||
allTags,
|
||||
onProgress,
|
||||
onLog
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('构建标签树失败:', error);
|
||||
this.addLog(onLog, `Failed to build tag tree: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量构建标签树
|
||||
* @param {Object} config - 配置信息
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async batchBuildTagTree(config) {
|
||||
const {
|
||||
projectId,
|
||||
topic,
|
||||
levels,
|
||||
tagsPerLevel,
|
||||
model,
|
||||
language,
|
||||
projectName,
|
||||
allTags: initialTags,
|
||||
onProgress,
|
||||
onLog
|
||||
} = config;
|
||||
|
||||
// 创建一个本地标签缓存,避免频繁请求服务器
|
||||
let allTags = [...initialTags];
|
||||
|
||||
// 构建父子关系映射
|
||||
const childrenMap = {};
|
||||
const parentMap = {};
|
||||
allTags.forEach(tag => {
|
||||
parentMap[tag.id] = tag;
|
||||
if (tag.parentId) {
|
||||
if (!childrenMap[tag.parentId]) {
|
||||
childrenMap[tag.parentId] = [];
|
||||
}
|
||||
childrenMap[tag.parentId].push(tag);
|
||||
}
|
||||
});
|
||||
|
||||
// 按层级分组标签,提高查找效率
|
||||
const tagsByLevel = {};
|
||||
allTags.forEach(tag => {
|
||||
const depth = this.getTagDepth(tag, parentMap);
|
||||
if (!tagsByLevel[depth]) {
|
||||
tagsByLevel[depth] = [];
|
||||
}
|
||||
tagsByLevel[depth].push(tag);
|
||||
});
|
||||
|
||||
// 批量创建各层级标签
|
||||
for (let level = 1; level <= levels; level++) {
|
||||
// 设置当前阶段
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: `level${level}`
|
||||
});
|
||||
}
|
||||
|
||||
// 确定当前层级的父标签
|
||||
let parentTags = [];
|
||||
if (level === 1) {
|
||||
// 第一层标签没有父标签
|
||||
parentTags = [null];
|
||||
} else {
|
||||
// 获取上一层的标签作为父标签
|
||||
parentTags = tagsByLevel[level - 1] || [];
|
||||
}
|
||||
|
||||
const batch = parentTags;
|
||||
const creationPromises = [];
|
||||
|
||||
for (const parentTag of batch) {
|
||||
// 获取当前父标签下的子标签
|
||||
let currentLevelTags = [];
|
||||
if (parentTag) {
|
||||
currentLevelTags = childrenMap[parentTag.id] || [];
|
||||
} else {
|
||||
// 根标签(没有父标签的标签)
|
||||
currentLevelTags = allTags.filter(tag => !tag.parentId);
|
||||
}
|
||||
|
||||
// 计算需要创建的标签数量
|
||||
const needToCreate = Math.max(0, tagsPerLevel - currentLevelTags.length);
|
||||
|
||||
if (needToCreate > 0) {
|
||||
// 构建标签路径
|
||||
let tagPathWithProjectName;
|
||||
if (level === 1) {
|
||||
// 第一层使用项目名称
|
||||
tagPathWithProjectName = projectName;
|
||||
} else {
|
||||
// 其他层构建完整路径
|
||||
const parentTagName = parentTag?.label || '';
|
||||
const parentTagPath = this.getTagPath(parentTag, parentMap);
|
||||
|
||||
if (!parentTagPath) {
|
||||
tagPathWithProjectName = projectName;
|
||||
} else if (!parentTagPath.startsWith(projectName)) {
|
||||
tagPathWithProjectName = `${projectName} > ${parentTagPath}`;
|
||||
} else {
|
||||
tagPathWithProjectName = parentTagPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建标签的Promise
|
||||
const createPromise = axios
|
||||
.post(`/api/projects/${projectId}/distill/tags`, {
|
||||
parentTag: level === 1 ? topic : parentTag?.label || '',
|
||||
parentTagId: parentTag ? parentTag.id : null,
|
||||
tagPath: tagPathWithProjectName || (level === 1 ? projectName : ''),
|
||||
count: needToCreate,
|
||||
model,
|
||||
language
|
||||
})
|
||||
.then(response => {
|
||||
// 更新本地标签缓存
|
||||
const newTags = response.data;
|
||||
allTags = [...allTags, ...newTags];
|
||||
|
||||
// 更新父子关系映射
|
||||
if (parentTag) {
|
||||
if (!childrenMap[parentTag.id]) {
|
||||
childrenMap[parentTag.id] = [];
|
||||
}
|
||||
childrenMap[parentTag.id].push(...newTags);
|
||||
}
|
||||
|
||||
// 更新父标签映射
|
||||
newTags.forEach(tag => {
|
||||
parentMap[tag.id] = tag;
|
||||
});
|
||||
|
||||
// 更新层级分组
|
||||
if (!tagsByLevel[level]) {
|
||||
tagsByLevel[level] = [];
|
||||
}
|
||||
tagsByLevel[level].push(...newTags);
|
||||
|
||||
// 更新构建的标签数量
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
tagsBuilt: newTags.length,
|
||||
updateType: 'increment'
|
||||
});
|
||||
}
|
||||
|
||||
// 添加日志
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Successfully created ${newTags.length} tags: ${newTags.map(tag => `"${tag.label}"`).join(', ')}`
|
||||
);
|
||||
|
||||
return newTags;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`创建${level}级标签失败:`, error);
|
||||
this.addLog(onLog, `Failed to create ${level} level tags: ${error.message || 'Unknown error'}`);
|
||||
return [];
|
||||
});
|
||||
|
||||
creationPromises.push(createPromise);
|
||||
}
|
||||
}
|
||||
|
||||
// 并行执行当前批次的所有创建任务
|
||||
await Promise.all(creationPromises);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为标签生成问题
|
||||
* @param {Object} config - 配置信息
|
||||
* @param {string} config.projectId - 项目ID
|
||||
* @param {number} config.levels - 标签层级
|
||||
* @param {number} config.questionsPerTag - 每个标签问题数量
|
||||
* @param {Object} config.model - 模型信息
|
||||
* @param {string} config.language - 语言
|
||||
* @param {Function} config.onProgress - 进度回调
|
||||
* @param {Function} config.onLog - 日志回调
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async generateQuestionsForTags(config) {
|
||||
const { projectId, levels, questionsPerTag, model, language, concurrencyLimit = 5, onProgress, onLog } = config;
|
||||
|
||||
// 设置当前阶段
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'questions'
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(onLog, 'Tag tree built, starting to generate questions for leaf tags...');
|
||||
|
||||
try {
|
||||
// 获取所有标签
|
||||
const response = await axios.get(`/api/projects/${projectId}/distill/tags/all`);
|
||||
const allTags = response.data;
|
||||
|
||||
// 找出所有叶子标签(没有子标签的标签)
|
||||
const leafTags = [];
|
||||
|
||||
// 创建一个映射表,记录每个标签的子标签
|
||||
const childrenMap = {};
|
||||
const parentMap = {};
|
||||
allTags.forEach(tag => {
|
||||
parentMap[tag.id] = tag;
|
||||
if (tag.parentId) {
|
||||
if (!childrenMap[tag.parentId]) {
|
||||
childrenMap[tag.parentId] = [];
|
||||
}
|
||||
childrenMap[tag.parentId].push(tag);
|
||||
}
|
||||
});
|
||||
|
||||
// 找出所有叶子标签
|
||||
allTags.forEach(tag => {
|
||||
// 如果没有子标签,并且深度是最大层级,则为叶子标签
|
||||
if (!childrenMap[tag.id] && this.getTagDepth(tag, parentMap) === levels) {
|
||||
leafTags.push(tag);
|
||||
}
|
||||
});
|
||||
|
||||
this.addLog(onLog, `Found ${leafTags.length} leaf tags, starting to generate questions...`);
|
||||
|
||||
// 获取所有问题
|
||||
const questionsResponse = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`);
|
||||
const allQuestions = questionsResponse.data;
|
||||
|
||||
// 更新总问题数量
|
||||
const totalQuestionsToGenerate = leafTags.length * questionsPerTag;
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
questionsTotal: totalQuestionsToGenerate
|
||||
});
|
||||
}
|
||||
|
||||
// 准备并发任务
|
||||
const generateQuestionTasks = [];
|
||||
const processedTags = [];
|
||||
|
||||
// 准备所有需要生成问题的叶子标签任务
|
||||
for (const tag of leafTags) {
|
||||
// 获取标签路径
|
||||
const tagPath = this.getTagPath(tag, parentMap);
|
||||
|
||||
// 计算已有问题数量
|
||||
const existingQuestions = allQuestions.filter(q => q.label === tag.label);
|
||||
const needToCreate = Math.max(0, questionsPerTag - existingQuestions.length);
|
||||
|
||||
if (needToCreate > 0) {
|
||||
// 只添加需要生成问题的标签任务
|
||||
generateQuestionTasks.push({
|
||||
tag,
|
||||
tagPath,
|
||||
needToCreate
|
||||
});
|
||||
|
||||
this.addLog(onLog, `Preparing to generate ${needToCreate} questions for tag "${tag.label}"...`);
|
||||
} else {
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Tag "${tag.label}" already has ${existingQuestions.length} questions, no need to generate new questions`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 分批执行生成问题任务,控制并发数
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Total ${generateQuestionTasks.length} tags need questions, concurrency limit: ${concurrencyLimit}`
|
||||
);
|
||||
|
||||
// 使用分组批量处理
|
||||
for (let i = 0; i < generateQuestionTasks.length; i += concurrencyLimit) {
|
||||
const batch = generateQuestionTasks.slice(i, i + concurrencyLimit);
|
||||
|
||||
// 并行处理批次任务
|
||||
await Promise.all(
|
||||
batch.map(async task => {
|
||||
const { tag, tagPath, needToCreate } = task;
|
||||
|
||||
this.addLog(onLog, `Generating ${needToCreate} questions for tag "${tag.label}"...`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/projects/${projectId}/distill/questions`, {
|
||||
tagPath,
|
||||
currentTag: tag.label,
|
||||
tagId: tag.id,
|
||||
count: needToCreate,
|
||||
model,
|
||||
language
|
||||
});
|
||||
|
||||
// 更新生成的问题数量
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
questionsBuilt: response.data.length,
|
||||
updateType: 'increment'
|
||||
});
|
||||
}
|
||||
this.addLog(onLog, `Successfully generated ${response.data.length} questions for tag "${tag.label}"`);
|
||||
} catch (error) {
|
||||
console.error(`为标签 "${tag.label}" 生成问题失败:`, error);
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Failed to generate questions for tag "${tag.label}": ${error.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 每完成一批,输出一次进度日志
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Completed batch ${Math.min(i + concurrencyLimit, generateQuestionTasks.length)}/${generateQuestionTasks.length} of question generation`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error);
|
||||
this.addLog(onLog, `Failed to get tags: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为问题生成数据集
|
||||
* @param {Object} config - 配置信息
|
||||
* @param {string} config.projectId - 项目ID
|
||||
* @param {Object} config.model - 模型信息
|
||||
* @param {string} config.language - 语言
|
||||
* @param {Function} config.onProgress - 进度回调
|
||||
* @param {Function} config.onLog - 日志回调
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async generateDatasetsForQuestions(config) {
|
||||
const { projectId, model, language, concurrencyLimit = 5, onProgress, onLog } = config;
|
||||
|
||||
// 设置当前阶段
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'datasets'
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(onLog, 'Question generation completed, starting to generate answers...');
|
||||
|
||||
try {
|
||||
// 获取所有问题
|
||||
const response = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`);
|
||||
const allQuestions = response.data;
|
||||
|
||||
// 找出未回答的问题
|
||||
const unansweredQuestions = allQuestions.filter(q => !q.answered);
|
||||
const answeredQuestions = allQuestions.filter(q => q.answered);
|
||||
|
||||
// 更新总数据集数量和已生成数量
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
datasetsTotal: allQuestions.length, // 总数据集数量应为总问题数量
|
||||
datasetsBuilt: answeredQuestions.length // 已生成的数据集数量即已回答的问题数量
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(onLog, `Found ${unansweredQuestions.length} unanswered questions, preparing to generate answers...`);
|
||||
this.addLog(onLog, `Dataset generation concurrency limit: ${concurrencyLimit}`);
|
||||
|
||||
// 分批处理未回答的问题,控制并发数
|
||||
for (let i = 0; i < unansweredQuestions.length; i += concurrencyLimit) {
|
||||
const batch = unansweredQuestions.slice(i, i + concurrencyLimit);
|
||||
|
||||
// 并行处理批次任务
|
||||
await Promise.all(
|
||||
batch.map(async question => {
|
||||
const questionContent = `${question.label} 下的问题ID:${question.id}`;
|
||||
this.addLog(onLog, `Generating answer for "${questionContent}"...`);
|
||||
|
||||
try {
|
||||
// 调用生成数据集的函数
|
||||
await this.generateSingleDataset({
|
||||
projectId,
|
||||
questionId: question.id,
|
||||
questionInfo: question,
|
||||
model,
|
||||
language
|
||||
});
|
||||
|
||||
// 更新生成的数据集数量
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
datasetsBuilt: 1,
|
||||
updateType: 'increment'
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(onLog, `Successfully generated answer for question "${questionContent}"`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate dataset for question "${question.id}":`, error);
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Failed to generate answer for question "${questionContent}": ${error.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 每完成一批,输出一次进度日志
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Completed batch ${Math.min(i + concurrencyLimit, unansweredQuestions.length)}/${unansweredQuestions.length} of dataset generation`
|
||||
);
|
||||
}
|
||||
|
||||
this.addLog(onLog, 'Dataset generation completed');
|
||||
} catch (error) {
|
||||
console.error('Dataset generation failed:', error);
|
||||
this.addLog(onLog, `Dataset generation error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为问题生成多轮对话数据集
|
||||
*/
|
||||
async generateMultiTurnDatasetsForQuestions(config) {
|
||||
const { projectId, model, language, concurrencyLimit = 2, onProgress, onLog } = config;
|
||||
|
||||
// 设置当前阶段
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'multi-turn-datasets'
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(onLog, 'Question generation completed, starting to generate multi-turn conversations...');
|
||||
|
||||
try {
|
||||
// 获取项目的多轮对话配置
|
||||
const configResponse = await axios.get(`/api/projects/${projectId}/tasks`);
|
||||
const taskConfig = configResponse.data;
|
||||
|
||||
const multiTurnConfig = {
|
||||
systemPrompt: taskConfig.multiTurnSystemPrompt || '',
|
||||
scenario: taskConfig.multiTurnScenario || '',
|
||||
rounds: taskConfig.multiTurnRounds || 3,
|
||||
roleA: taskConfig.multiTurnRoleA || '',
|
||||
roleB: taskConfig.multiTurnRoleB || ''
|
||||
};
|
||||
|
||||
// 检查是否已配置必要的多轮对话设置
|
||||
if (
|
||||
!multiTurnConfig.scenario ||
|
||||
!multiTurnConfig.roleA ||
|
||||
!multiTurnConfig.roleB ||
|
||||
!multiTurnConfig.rounds ||
|
||||
multiTurnConfig.rounds < 1
|
||||
) {
|
||||
throw new Error('项目未配置多轮对话参数,请先在项目设置中配置多轮对话相关参数');
|
||||
}
|
||||
|
||||
// 获取所有已回答的问题(多轮对话需要基于已有答案的问题)
|
||||
const response = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`);
|
||||
const allQuestions = response.data;
|
||||
const answeredQuestions = allQuestions;
|
||||
|
||||
if (answeredQuestions.length === 0) {
|
||||
this.addLog(onLog, 'No answered questions found, skipping multi-turn conversation generation');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取已生成多轮对话的问题ID
|
||||
const conversationsResponse = await axios.get(`/api/projects/${projectId}/dataset-conversations?pageSize=1000`);
|
||||
const existingConversationIds = new Set(
|
||||
(conversationsResponse.data.conversations || []).map(conv => conv.questionId)
|
||||
);
|
||||
|
||||
// 筛选未生成多轮对话的问题
|
||||
const questionsForMultiTurn = answeredQuestions.filter(q => !existingConversationIds.has(q.id));
|
||||
|
||||
// 更新多轮对话数据集总数和已生成数量
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
multiTurnDatasetsTotal: answeredQuestions.length,
|
||||
multiTurnDatasetsBuilt: answeredQuestions.length - questionsForMultiTurn.length
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Found ${questionsForMultiTurn.length} questions ready for multi-turn conversation generation...`
|
||||
);
|
||||
this.addLog(onLog, `Multi-turn generation concurrency limit: ${concurrencyLimit}`);
|
||||
|
||||
// 分批处理未生成多轮对话的问题,控制并发数
|
||||
for (let i = 0; i < questionsForMultiTurn.length; i += concurrencyLimit) {
|
||||
const batch = questionsForMultiTurn.slice(i, i + concurrencyLimit);
|
||||
|
||||
// 并行处理批次任务
|
||||
await Promise.all(
|
||||
batch.map(async question => {
|
||||
const questionContent = `${question.label} 下的问题ID:${question.id}`;
|
||||
this.addLog(onLog, `Generating multi-turn conversation for "${questionContent}"...`);
|
||||
|
||||
try {
|
||||
// 调用生成多轮对话的函数
|
||||
await this.generateSingleMultiTurnDataset({
|
||||
projectId,
|
||||
questionId: question.id,
|
||||
questionInfo: question,
|
||||
model,
|
||||
language,
|
||||
multiTurnConfig
|
||||
});
|
||||
|
||||
// 更新进度
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
multiTurnDatasetsBuilt: 1,
|
||||
updateType: 'increment'
|
||||
});
|
||||
}
|
||||
|
||||
this.addLog(onLog, `Multi-turn conversation generated for "${questionContent}"`);
|
||||
} catch (error) {
|
||||
this.addLog(
|
||||
onLog,
|
||||
`Failed to generate multi-turn conversation for "${questionContent}": ${error.message}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.addLog(onLog, 'Multi-turn conversation generation completed');
|
||||
} catch (error) {
|
||||
console.error('Multi-turn dataset generation failed:', error);
|
||||
this.addLog(onLog, `Multi-turn dataset generation error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单个问题的多轮对话数据集
|
||||
*/
|
||||
async generateSingleMultiTurnDataset({ projectId, questionId, questionInfo, model, language, multiTurnConfig }) {
|
||||
try {
|
||||
const response = await axios.post(`/api/projects/${projectId}/dataset-conversations`, {
|
||||
questionId,
|
||||
...multiTurnConfig,
|
||||
model,
|
||||
language
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate multi-turn dataset:', error);
|
||||
throw new Error(`Failed to generate multi-turn dataset: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单个问题的数据集
|
||||
*/
|
||||
async generateSingleDataset({ projectId, questionId, questionInfo, model, language }) {
|
||||
try {
|
||||
// 获取问题信息
|
||||
let question = questionInfo;
|
||||
if (!question) {
|
||||
const response = await axios.get(`/api/projects/${projectId}/questions/${questionId}`);
|
||||
question = response.data;
|
||||
}
|
||||
|
||||
// 生成数据集
|
||||
const response = await axios.post(`/api/projects/${projectId}/datasets`, {
|
||||
projectId,
|
||||
questionId,
|
||||
model,
|
||||
language: language || 'zh-CN'
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate dataset:', error);
|
||||
throw new Error(`Failed to generate dataset: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签深度
|
||||
* @param {Object} tag - 标签信息
|
||||
* @param {Object} parentMap - 父标签映射
|
||||
* @returns {number} - 标签深度
|
||||
*/
|
||||
getTagDepth(tag, parentMap) {
|
||||
if (!tag) return 0;
|
||||
|
||||
let depth = 1;
|
||||
let currentTag = tag;
|
||||
|
||||
while (currentTag && currentTag.parentId) {
|
||||
depth++;
|
||||
currentTag = parentMap[currentTag.parentId];
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签路径,确保始终以项目名称开头
|
||||
* @param {Object|null} tag - 标签对象
|
||||
* @param {Object} parentMap - 父标签映射
|
||||
* @returns {string} 标签路径
|
||||
*/
|
||||
getTagPath(tag, parentMap) {
|
||||
if (!tag) return '';
|
||||
|
||||
// 使用已经获取的项目名称
|
||||
const projectName = this.projectName || '';
|
||||
|
||||
// 构建标签路径
|
||||
const path = [];
|
||||
let currentTag = tag;
|
||||
|
||||
while (currentTag) {
|
||||
path.unshift(currentTag.label);
|
||||
if (currentTag.parentId) {
|
||||
currentTag = parentMap[currentTag.parentId];
|
||||
} else {
|
||||
currentTag = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保路径以项目名称开头
|
||||
if (projectName && path.length > 0 && path[0] !== projectName) {
|
||||
path.unshift(projectName);
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加日志
|
||||
* @param {Function} onLog - 日志回调
|
||||
* @param {string} message - 日志消息
|
||||
*/
|
||||
addLog(onLog, message) {
|
||||
if (onLog && typeof onLog === 'function') {
|
||||
onLog(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const autoDistillService = new AutoDistillService();
|
||||
export default autoDistillService;
|
||||
528
easy-dataset-main/app/projects/[projectId]/distill/page.js
Normal file
528
easy-dataset-main/app/projects/[projectId]/distill/page.js
Normal file
@@ -0,0 +1,528 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
import { Box, Typography, Paper, Container, Button, CircularProgress, Alert, IconButton, Tooltip } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import DistillTreeView from '@/components/distill/DistillTreeView';
|
||||
import TagGenerationDialog from '@/components/distill/TagGenerationDialog';
|
||||
import QuestionGenerationDialog from '@/components/distill/QuestionGenerationDialog';
|
||||
import AutoDistillDialog from '@/components/distill/AutoDistillDialog';
|
||||
import AutoDistillProgress from '@/components/distill/AutoDistillProgress';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import { autoDistillService } from './autoDistillService';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function DistillPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { projectId } = useParams();
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [tags, setTags] = useState([]);
|
||||
|
||||
// 标签生成对话框相关状态
|
||||
const [tagDialogOpen, setTagDialogOpen] = useState(false);
|
||||
const [questionDialogOpen, setQuestionDialogOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState(null);
|
||||
const [selectedTagPath, setSelectedTagPath] = useState('');
|
||||
|
||||
// 自动蒸馏相关状态
|
||||
const [autoDistillDialogOpen, setAutoDistillDialogOpen] = useState(false);
|
||||
const [autoDistillProgressOpen, setAutoDistillProgressOpen] = useState(false);
|
||||
const [autoDistillRunning, setAutoDistillRunning] = useState(false);
|
||||
const [distillStats, setDistillStats] = useState({
|
||||
tagsCount: 0,
|
||||
questionsCount: 0,
|
||||
datasetsCount: 0,
|
||||
multiTurnDatasetsCount: 0
|
||||
});
|
||||
const [distillProgress, setDistillProgress] = useState({
|
||||
stage: 'initializing',
|
||||
tagsTotal: 0,
|
||||
tagsBuilt: 0,
|
||||
questionsTotal: 0,
|
||||
questionsBuilt: 0,
|
||||
datasetsTotal: 0,
|
||||
datasetsBuilt: 0,
|
||||
multiTurnDatasetsTotal: 0, // 新增多轮对话数据集总数
|
||||
multiTurnDatasetsBuilt: 0, // 新增多轮对话数据集已生成数
|
||||
logs: []
|
||||
});
|
||||
|
||||
const treeViewRef = useRef(null);
|
||||
|
||||
// 获取项目信息和标签列表
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchProject();
|
||||
fetchTags();
|
||||
fetchDistillStats();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 监听多轮对话数据集刷新事件
|
||||
useEffect(() => {
|
||||
const handleRefreshStats = () => {
|
||||
fetchDistillStats();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('refreshDistillStats', handleRefreshStats);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('refreshDistillStats', handleRefreshStats);
|
||||
};
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 获取项目信息
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取项目信息失败:', error);
|
||||
setError(t('common.fetchError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取标签列表
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/projects/${projectId}/distill/tags/all`);
|
||||
setTags(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取标签列表失败:', error);
|
||||
setError(t('common.fetchError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取蒸馏统计信息
|
||||
const fetchDistillStats = async () => {
|
||||
try {
|
||||
// 获取标签数量
|
||||
const tagsResponse = await axios.get(`/api/projects/${projectId}/distill/tags/all`);
|
||||
const tagsCount = tagsResponse.data.length;
|
||||
|
||||
// 获取问题数量
|
||||
const questionsResponse = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`);
|
||||
const questionsCount = questionsResponse.data.length;
|
||||
|
||||
// 获取数据集数量
|
||||
const datasetsCount = questionsResponse.data.filter(q => q.answered).length;
|
||||
|
||||
// 获取多轮对话数据集数量
|
||||
let multiTurnDatasetsCount = 0;
|
||||
try {
|
||||
const conversationsResponse = await axios.get(
|
||||
`/api/projects/${projectId}/dataset-conversations?getAllIds=true`
|
||||
);
|
||||
multiTurnDatasetsCount = (conversationsResponse.data.allConversationIds || []).length;
|
||||
} catch (error) {
|
||||
console.log('获取多轮对话数据集统计失败,可能是API不存在:', error.message);
|
||||
}
|
||||
|
||||
setDistillStats({
|
||||
tagsCount,
|
||||
questionsCount,
|
||||
datasetsCount,
|
||||
multiTurnDatasetsCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取蒸馏统计信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开生成标签对话框
|
||||
const handleOpenTagDialog = (tag = null, tagPath = '') => {
|
||||
if (!selectedModel || Object.keys(selectedModel).length === 0) {
|
||||
setError(t('distill.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
setSelectedTag(tag);
|
||||
setSelectedTagPath(tagPath);
|
||||
setTagDialogOpen(true);
|
||||
};
|
||||
|
||||
// 打开生成问题对话框
|
||||
const handleOpenQuestionDialog = (tag, tagPath) => {
|
||||
if (!selectedModel || Object.keys(selectedModel).length === 0) {
|
||||
setError(t('distill.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
setSelectedTag(tag);
|
||||
setSelectedTagPath(tagPath);
|
||||
setQuestionDialogOpen(true);
|
||||
};
|
||||
|
||||
// 处理标签生成完成
|
||||
const handleTagGenerated = () => {
|
||||
fetchTags(); // 重新获取标签列表
|
||||
setTagDialogOpen(false);
|
||||
};
|
||||
|
||||
// 处理问题生成完成
|
||||
const handleQuestionGenerated = () => {
|
||||
// 关闭对话框
|
||||
setQuestionDialogOpen(false);
|
||||
|
||||
// 刷新标签数据
|
||||
fetchTags();
|
||||
fetchDistillStats();
|
||||
|
||||
// 如果 treeViewRef 存在且有 fetchQuestionsStats 方法,则调用它刷新问题统计信息
|
||||
if (treeViewRef.current && typeof treeViewRef.current.fetchQuestionsStats === 'function') {
|
||||
treeViewRef.current.fetchQuestionsStats();
|
||||
}
|
||||
};
|
||||
|
||||
// 打开自动蒸馏对话框
|
||||
const handleOpenAutoDistillDialog = () => {
|
||||
if (!selectedModel || Object.keys(selectedModel).length === 0) {
|
||||
setError(t('distill.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
setAutoDistillDialogOpen(true);
|
||||
};
|
||||
|
||||
// 开始自动蒸馏任务(前台运行)
|
||||
const handleStartAutoDistill = async config => {
|
||||
setAutoDistillDialogOpen(false);
|
||||
setAutoDistillProgressOpen(true);
|
||||
setAutoDistillRunning(true);
|
||||
|
||||
// 初始化进度信息
|
||||
setDistillProgress({
|
||||
stage: 'initializing',
|
||||
tagsTotal: config.estimatedTags,
|
||||
tagsBuilt: distillStats.tagsCount || 0,
|
||||
questionsTotal: config.estimatedQuestions,
|
||||
questionsBuilt: distillStats.questionsCount || 0,
|
||||
datasetsTotal: config.estimatedQuestions, // 初步设置数据集总数为问题数,后面会更新
|
||||
datasetsBuilt: distillStats.datasetsCount || 0, // 根据当前已生成的数据集数量初始化
|
||||
multiTurnDatasetsTotal:
|
||||
config.datasetType === 'multi-turn' || config.datasetType === 'both' ? config.estimatedQuestions : 0,
|
||||
multiTurnDatasetsBuilt: distillStats.multiTurnDatasetsCount || 0,
|
||||
logs: [t('distill.autoDistillStarted', { time: new Date().toLocaleTimeString() })]
|
||||
});
|
||||
|
||||
try {
|
||||
// 检查模型是否存在
|
||||
if (!selectedModel || Object.keys(selectedModel).length === 0) {
|
||||
addLog(t('distill.selectModelFirst'));
|
||||
setAutoDistillRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 autoDistillService 执行蒸馏任务
|
||||
await autoDistillService.executeDistillTask({
|
||||
projectId,
|
||||
topic: config.topic,
|
||||
levels: config.levels,
|
||||
tagsPerLevel: config.tagsPerLevel,
|
||||
questionsPerTag: config.questionsPerTag,
|
||||
datasetType: config.datasetType, // 新增数据集类型参数
|
||||
model: selectedModel,
|
||||
language: i18n.language,
|
||||
concurrencyLimit: project?.taskConfig?.concurrencyLimit || 5, // 从项目配置中获取并发限制
|
||||
onProgress: updateProgress,
|
||||
onLog: addLog
|
||||
});
|
||||
|
||||
// 更新任务状态
|
||||
setAutoDistillRunning(false);
|
||||
} catch (error) {
|
||||
console.error('自动蒸馏任务执行失败:', error);
|
||||
addLog(t('distill.taskExecutionError', { error: error.message || t('common.unknownError') }));
|
||||
setAutoDistillRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始自动蒸馏任务(后台运行)
|
||||
const handleStartAutoDistillBackground = async config => {
|
||||
setAutoDistillDialogOpen(false);
|
||||
|
||||
try {
|
||||
// 检查模型是否存在
|
||||
if (!selectedModel || Object.keys(selectedModel).length === 0) {
|
||||
setError(t('distill.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建后台任务
|
||||
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
|
||||
taskType: 'data-distillation',
|
||||
modelInfo: selectedModel,
|
||||
language: i18n.language,
|
||||
detail: t('distill.autoDistillTaskDetail', { topic: config.topic }),
|
||||
totalCount: config.estimatedQuestions,
|
||||
note: {
|
||||
topic: config.topic,
|
||||
levels: config.levels,
|
||||
tagsPerLevel: config.tagsPerLevel,
|
||||
questionsPerTag: config.questionsPerTag,
|
||||
datasetType: config.datasetType,
|
||||
estimatedTags: config.estimatedTags,
|
||||
estimatedQuestions: config.estimatedQuestions
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.code === 0) {
|
||||
toast.success(t('distill.backgroundTaskCreated'));
|
||||
// 3秒后刷新统计信息
|
||||
setTimeout(() => {
|
||||
fetchDistillStats();
|
||||
}, 3000);
|
||||
} else {
|
||||
toast.error(response.data.message || t('distill.backgroundTaskFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建后台蒸馏任务失败:', error);
|
||||
toast.error(error.message || t('distill.backgroundTaskFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 更新进度
|
||||
const updateProgress = progressUpdate => {
|
||||
setDistillProgress(prev => {
|
||||
const newProgress = { ...prev };
|
||||
|
||||
// 更新阶段
|
||||
if (progressUpdate.stage) {
|
||||
newProgress.stage = progressUpdate.stage;
|
||||
}
|
||||
|
||||
// 更新标签总数
|
||||
if (progressUpdate.tagsTotal) {
|
||||
newProgress.tagsTotal = progressUpdate.tagsTotal;
|
||||
}
|
||||
|
||||
// 更新已构建标签数
|
||||
if (progressUpdate.tagsBuilt) {
|
||||
if (progressUpdate.updateType === 'increment') {
|
||||
newProgress.tagsBuilt += progressUpdate.tagsBuilt;
|
||||
} else {
|
||||
newProgress.tagsBuilt = progressUpdate.tagsBuilt;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新问题总数
|
||||
if (progressUpdate.questionsTotal) {
|
||||
newProgress.questionsTotal = progressUpdate.questionsTotal;
|
||||
}
|
||||
|
||||
// 更新已生成问题数
|
||||
if (progressUpdate.questionsBuilt) {
|
||||
if (progressUpdate.updateType === 'increment') {
|
||||
newProgress.questionsBuilt += progressUpdate.questionsBuilt;
|
||||
} else {
|
||||
newProgress.questionsBuilt = progressUpdate.questionsBuilt;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据集总数
|
||||
if (progressUpdate.datasetsTotal) {
|
||||
newProgress.datasetsTotal = progressUpdate.datasetsTotal;
|
||||
}
|
||||
|
||||
// 更新已生成数据集数
|
||||
if (progressUpdate.datasetsBuilt) {
|
||||
if (progressUpdate.updateType === 'increment') {
|
||||
newProgress.datasetsBuilt += progressUpdate.datasetsBuilt;
|
||||
} else {
|
||||
newProgress.datasetsBuilt = progressUpdate.datasetsBuilt;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新多轮对话数据集总数
|
||||
if (progressUpdate.multiTurnDatasetsTotal) {
|
||||
newProgress.multiTurnDatasetsTotal = progressUpdate.multiTurnDatasetsTotal;
|
||||
}
|
||||
|
||||
// 更新已生成多轮对话数据集数
|
||||
if (progressUpdate.multiTurnDatasetsBuilt) {
|
||||
if (progressUpdate.updateType === 'increment') {
|
||||
newProgress.multiTurnDatasetsBuilt += progressUpdate.multiTurnDatasetsBuilt;
|
||||
} else {
|
||||
newProgress.multiTurnDatasetsBuilt = progressUpdate.multiTurnDatasetsBuilt;
|
||||
}
|
||||
}
|
||||
|
||||
return newProgress;
|
||||
});
|
||||
};
|
||||
|
||||
// 添加日志,最多保留200条
|
||||
const addLog = message => {
|
||||
setDistillProgress(prev => {
|
||||
const newLogs = [...prev.logs, message];
|
||||
// 如果日志超过200条,只保留最新的200条
|
||||
const limitedLogs = newLogs.length > 200 ? newLogs.slice(-200) : newLogs;
|
||||
return {
|
||||
...prev,
|
||||
logs: limitedLogs
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 关闭进度对话框
|
||||
const handleCloseProgressDialog = () => {
|
||||
if (!autoDistillRunning) {
|
||||
setAutoDistillProgressOpen(false);
|
||||
// 刷新数据
|
||||
fetchTags();
|
||||
fetchDistillStats();
|
||||
if (treeViewRef.current && typeof treeViewRef.current.fetchQuestionsStats === 'function') {
|
||||
treeViewRef.current.fetchQuestionsStats();
|
||||
}
|
||||
} else {
|
||||
// 如果任务还在运行,可以展示一个确认对话框
|
||||
// 这里简化处理,直接关闭
|
||||
setAutoDistillProgressOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{t('common.projectIdRequired')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 8 }}>
|
||||
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, paddingLeft: '32px' }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h5" component="h1" fontWeight="bold">
|
||||
{t('distill.title')}
|
||||
</Typography>
|
||||
<Tooltip title={t('common.help')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const helpUrl =
|
||||
i18n.language === 'en'
|
||||
? 'https://docs.easy-dataset.com/ed/en/advanced/images-and-media'
|
||||
: 'https://docs.easy-dataset.com/jin-jie-shi-yong/images-and-media';
|
||||
window.open(helpUrl, '_blank');
|
||||
}}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
<HelpOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleOpenAutoDistillDialog}
|
||||
disabled={!selectedModel}
|
||||
startIcon={<AutoFixHighIcon />}
|
||||
sx={{ px: 3, py: 1 }}
|
||||
>
|
||||
{t('distill.autoDistillButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={() => handleOpenTagDialog(null)}
|
||||
disabled={!selectedModel}
|
||||
startIcon={<AddIcon />}
|
||||
sx={{ px: 3, py: 1 }}
|
||||
>
|
||||
{t('distill.generateRootTags')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 4, px: 3, py: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 6 }}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<DistillTreeView
|
||||
ref={treeViewRef}
|
||||
projectId={projectId}
|
||||
tags={tags}
|
||||
onGenerateSubTags={handleOpenTagDialog}
|
||||
onGenerateQuestions={handleOpenQuestionDialog}
|
||||
onTagsUpdate={setTags}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* 生成标签对话框 */}
|
||||
{tagDialogOpen && (
|
||||
<TagGenerationDialog
|
||||
open={tagDialogOpen}
|
||||
onClose={() => setTagDialogOpen(false)}
|
||||
onGenerated={handleTagGenerated}
|
||||
projectId={projectId}
|
||||
parentTag={selectedTag}
|
||||
tagPath={selectedTagPath}
|
||||
model={selectedModel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 生成问题对话框 */}
|
||||
{questionDialogOpen && (
|
||||
<QuestionGenerationDialog
|
||||
open={questionDialogOpen}
|
||||
onClose={() => setQuestionDialogOpen(false)}
|
||||
onGenerated={handleQuestionGenerated}
|
||||
projectId={projectId}
|
||||
tag={selectedTag}
|
||||
tagPath={selectedTagPath}
|
||||
model={selectedModel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 全自动蒸馏数据集配置对话框 */}
|
||||
<AutoDistillDialog
|
||||
open={autoDistillDialogOpen}
|
||||
onClose={() => setAutoDistillDialogOpen(false)}
|
||||
onStart={handleStartAutoDistill}
|
||||
onStartBackground={handleStartAutoDistillBackground}
|
||||
projectId={projectId}
|
||||
project={project}
|
||||
stats={distillStats}
|
||||
/>
|
||||
|
||||
{/* 全自动蒸馏进度对话框 */}
|
||||
<AutoDistillProgress
|
||||
open={autoDistillProgressOpen}
|
||||
onClose={handleCloseProgressDialog}
|
||||
progress={distillProgress}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Paper,
|
||||
Chip,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Stack,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
FormGroup,
|
||||
Checkbox as MuiCheckbox
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import ShortTextIcon from '@mui/icons-material/ShortText';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import TagIcon from '@mui/icons-material/Tag';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
|
||||
import useEvalDatasetDetails from './useEvalDatasetDetails';
|
||||
import EvalDatasetHeader from '../components/EvalDatasetHeader';
|
||||
import EvalEditableField from '../components/EvalEditableField';
|
||||
import TagSelector from '@/components/datasets/TagSelector';
|
||||
|
||||
// 题型图标和颜色映射
|
||||
const QUESTION_TYPE_CONFIG = {
|
||||
true_false: {
|
||||
icon: CheckCircleIcon,
|
||||
color: 'success',
|
||||
bgColor: 'success.light'
|
||||
},
|
||||
single_choice: {
|
||||
icon: RadioButtonCheckedIcon,
|
||||
color: 'primary',
|
||||
bgColor: 'primary.light'
|
||||
},
|
||||
multiple_choice: {
|
||||
icon: CheckBoxIcon,
|
||||
color: 'secondary',
|
||||
bgColor: 'secondary.light'
|
||||
},
|
||||
short_answer: {
|
||||
icon: ShortTextIcon,
|
||||
color: 'warning',
|
||||
bgColor: 'warning.light'
|
||||
},
|
||||
open_ended: {
|
||||
icon: NotesIcon,
|
||||
color: 'info',
|
||||
bgColor: 'info.light'
|
||||
}
|
||||
};
|
||||
|
||||
export default function EvalDatasetDetailPage() {
|
||||
const { projectId, evalId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
|
||||
const { data, loading, error, handleNavigate, handleSave, handleDelete } = useEvalDatasetDetails(projectId, evalId);
|
||||
|
||||
// 获取项目中已使用的标签
|
||||
useEffect(() => {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/tags`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setAvailableTags(result.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取可用标签失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId && !loading) {
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [projectId, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{error || t('eval.notFound')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const typeConfig = QUESTION_TYPE_CONFIG[data.questionType] || QUESTION_TYPE_CONFIG.short_answer;
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
// 解析选项
|
||||
let options = [];
|
||||
try {
|
||||
options = data.options ? (typeof data.options === 'string' ? JSON.parse(data.options) : data.options) : [];
|
||||
} catch (e) {
|
||||
options = [];
|
||||
}
|
||||
|
||||
// 渲染选项预览
|
||||
const renderOptionsPreview = value => {
|
||||
let opts = [];
|
||||
try {
|
||||
opts = value ? (typeof value === 'string' ? JSON.parse(value) : value) : [];
|
||||
} catch (e) {
|
||||
return <Typography color="error">Invalid JSON format</Typography>;
|
||||
}
|
||||
|
||||
if (!Array.isArray(opts) || opts.length === 0) {
|
||||
return <Typography color="text.secondary">{t('common.noData')}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{opts.map((option, index) => {
|
||||
const optionLabel = String.fromCharCode(65 + index);
|
||||
const isCorrect =
|
||||
data.questionType === 'multiple_choice'
|
||||
? (Array.isArray(data.correctAnswer)
|
||||
? data.correctAnswer
|
||||
: JSON.parse(data.correctAnswer || '[]')
|
||||
).includes(optionLabel)
|
||||
: data.correctAnswer === optionLabel;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: isCorrect ? alpha(theme.palette.success.main, 0.08) : 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: isCorrect ? alpha(theme.palette.success.main, 0.3) : 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: isCorrect ? 'success.main' : 'text.secondary',
|
||||
minWidth: 24
|
||||
}}
|
||||
>
|
||||
{optionLabel}.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: isCorrect ? 'success.dark' : 'text.primary'
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</Typography>
|
||||
{isCorrect && (
|
||||
<Chip
|
||||
label={t('eval.correct')}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ ml: 'auto', height: 20, fontSize: '0.75rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染答案编辑组件
|
||||
const renderAnswerEditor = (currentValue, onChange) => {
|
||||
if (data.questionType === 'true_false') {
|
||||
return (
|
||||
<RadioGroup value={currentValue} onChange={e => onChange(e.target.value)} row>
|
||||
<FormControlLabel value="✅" control={<Radio />} label={t('eval.correct')} />
|
||||
<FormControlLabel value="❌" control={<Radio />} label={t('eval.wrong')} />
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.questionType === 'single_choice') {
|
||||
return (
|
||||
<RadioGroup value={currentValue} onChange={e => onChange(e.target.value)}>
|
||||
{options.map((_, index) => {
|
||||
const label = String.fromCharCode(65 + index);
|
||||
return (
|
||||
<FormControlLabel key={label} value={label} control={<Radio />} label={`${label}. ${options[index]}`} />
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.questionType === 'multiple_choice') {
|
||||
const selected = Array.isArray(currentValue) ? currentValue : JSON.parse(currentValue || '[]');
|
||||
const handleChange = label => {
|
||||
const newSelected = selected.includes(label) ? selected.filter(i => i !== label) : [...selected, label].sort();
|
||||
onChange(JSON.stringify(newSelected));
|
||||
};
|
||||
|
||||
return (
|
||||
<FormGroup>
|
||||
{options.map((_, index) => {
|
||||
const label = String.fromCharCode(65 + index);
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={label}
|
||||
control={<MuiCheckbox checked={selected.includes(label)} onChange={() => handleChange(label)} />}
|
||||
label={`${label}. ${options[index]}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return null; // 简答题和开放题保持默认文本框
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 6 }}>
|
||||
<EvalDatasetHeader projectId={projectId} onNavigate={handleNavigate} onDelete={handleDelete} />
|
||||
|
||||
<Grid container spacing={3} alignItems="flex-start">
|
||||
{/* 左侧主要内容 */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ p: 4, borderRadius: 3 }}>
|
||||
{/* 题型标识 */}
|
||||
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Chip
|
||||
icon={<TypeIcon sx={{ fontSize: '18px !important' }} />}
|
||||
label={t(`eval.questionTypes.${data.questionType}`)}
|
||||
color={typeConfig.color}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
py: 0.5,
|
||||
height: 32
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
<AccessTimeIcon sx={{ fontSize: 14 }} />
|
||||
{new Date(data.createAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 问题 */}
|
||||
<EvalEditableField
|
||||
label={t('eval.question')}
|
||||
value={data.question}
|
||||
onSave={val => handleSave('question', val)}
|
||||
placeholder={t('eval.questionPlaceholder')}
|
||||
/>
|
||||
|
||||
{/* 选项 (仅选择题) */}
|
||||
{(data.questionType === 'single_choice' || data.questionType === 'multiple_choice') && (
|
||||
<EvalEditableField
|
||||
label={t('eval.options')}
|
||||
value={typeof data.options === 'string' ? data.options : JSON.stringify(data.options, null, 2)}
|
||||
onSave={val => handleSave('options', val)}
|
||||
placeholder={'["Option A", "Option B", ...]'}
|
||||
renderPreview={() => renderOptionsPreview(data.options)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 答案 */}
|
||||
<EvalEditableField
|
||||
label={t('eval.answer')}
|
||||
value={data.correctAnswer}
|
||||
onSave={val => handleSave('correctAnswer', val)}
|
||||
placeholder={t('eval.answerPlaceholder')}
|
||||
renderEditor={(val, setVal) => renderAnswerEditor(val, setVal)}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* 右侧侧边栏 */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack spacing={3} sx={{ position: 'sticky', top: 24 }}>
|
||||
{/* 来源信息 */}
|
||||
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<DescriptionIcon fontSize="small" />
|
||||
{t('eval.sourceChunk')}
|
||||
</Typography>
|
||||
{data.chunks ? (
|
||||
<>
|
||||
<Chip
|
||||
label={data.chunks.name || data.chunks.fileName}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ mb: 2, maxWidth: '100%' }}
|
||||
/>
|
||||
{data.chunks.content && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: 'text.secondary',
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{data.chunks.content}
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
{t('common.noData')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 标签和备注 */}
|
||||
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<TagIcon fontSize="small" />
|
||||
{t('eval.tags')}
|
||||
</Typography>
|
||||
<TagSelector
|
||||
value={
|
||||
data.tags
|
||||
? typeof data.tags === 'string'
|
||||
? data.tags
|
||||
.split(/[,,]/)
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
: []
|
||||
}
|
||||
onChange={newTags => handleSave('tags', newTags.join(', '))}
|
||||
availableTags={availableTags}
|
||||
placeholder={t('eval.tagsPlaceholder')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box>
|
||||
<EvalEditableField
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<NotesIcon fontSize="small" />
|
||||
{t('eval.note')}
|
||||
</Box>
|
||||
}
|
||||
value={data.note}
|
||||
onSave={val => handleSave('note', val)}
|
||||
placeholder={t('eval.notePlaceholder')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function useEvalDatasetDetails(projectId, evalId) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 编辑状态
|
||||
const [editingField, setEditingField] = useState(null); // 'question', 'options', 'correctAnswer', 'note', 'tags'
|
||||
const [fieldValue, setFieldValue] = useState('');
|
||||
// 获取详情
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('未找到该题目');
|
||||
}
|
||||
throw new Error('获取数据失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, evalId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 导航
|
||||
const handleNavigate = async direction => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=${direction}`);
|
||||
if (response.ok) {
|
||||
const neighbor = await response.json();
|
||||
if (neighbor && neighbor.id) {
|
||||
router.push(`/projects/${projectId}/eval-datasets/${neighbor.id}`);
|
||||
} else {
|
||||
toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条数据了`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Navigation error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始编辑
|
||||
const handleStartEdit = (field, value) => {
|
||||
setEditingField(field);
|
||||
// 对于 options,如果是数组则转为 JSON 字符串编辑,或者在组件层面处理
|
||||
// 这里假设 value 已经是适合编辑的格式
|
||||
setFieldValue(value);
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEdit = () => {
|
||||
setEditingField(null);
|
||||
setFieldValue('');
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSave = async (field, value) => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('保存失败');
|
||||
|
||||
const updated = await response.json();
|
||||
setData(prev => ({ ...prev, ...updated })); // 更新本地数据
|
||||
setEditingField(null);
|
||||
toast.success('保存成功');
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('确定要删除这条数据吗?此操作不可撤销。')) return;
|
||||
|
||||
try {
|
||||
// 先尝试获取下一条,以便删除后跳转
|
||||
const nextResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=next`);
|
||||
let nextId = null;
|
||||
if (nextResponse.ok) {
|
||||
const next = await nextResponse.json();
|
||||
if (next && next.id) nextId = next.id;
|
||||
}
|
||||
|
||||
// 如果没有下一条,尝试获取上一条
|
||||
if (!nextId) {
|
||||
const prevResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=prev`);
|
||||
if (prevResponse.ok) {
|
||||
const prev = await prevResponse.json();
|
||||
if (prev && prev.id) nextId = prev.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const deleteResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) throw new Error('删除失败');
|
||||
|
||||
toast.success('删除成功');
|
||||
|
||||
if (nextId) {
|
||||
router.replace(`/projects/${projectId}/eval-datasets/${nextId}`);
|
||||
} else {
|
||||
router.push(`/projects/${projectId}/eval-datasets`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
editingField,
|
||||
fieldValue,
|
||||
setFieldValue,
|
||||
handleNavigate,
|
||||
handleStartEdit,
|
||||
handleCancelEdit,
|
||||
handleSave,
|
||||
handleDelete
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Card,
|
||||
CardActionArea,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
InputAdornment,
|
||||
CircularProgress,
|
||||
DialogTitle,
|
||||
DialogContentText
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
import { StyledDialogTitle } from './ImportDialog.styles';
|
||||
import { DATA_SETS } from '../constants';
|
||||
|
||||
export default function BuiltinDatasetDialog({ open, onClose, projectId, onSuccess }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedDataset, setSelectedDataset] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const isZh = i18n.language.startsWith('zh');
|
||||
|
||||
// 过滤数据集
|
||||
const filteredDatasets = useMemo(() => {
|
||||
if (!keyword) return DATA_SETS;
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return DATA_SETS.filter(
|
||||
ds =>
|
||||
ds.zh.toLowerCase().includes(lowerKeyword) ||
|
||||
ds.en.toLowerCase().includes(lowerKeyword) ||
|
||||
ds.type.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}, [keyword]);
|
||||
|
||||
const handleCardClick = dataset => {
|
||||
setSelectedDataset(dataset);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmClose = () => {
|
||||
setConfirmOpen(false);
|
||||
setSelectedDataset(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedDataset) return;
|
||||
|
||||
setDownloading(true);
|
||||
setConfirmOpen(false);
|
||||
|
||||
try {
|
||||
const cdnUrl = `https://raw.githubusercontent.com/ConardLi/easy-dataset-eval/main/${selectedDataset.file}`;
|
||||
const response = await fetch(cdnUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch dataset: ${response.statusText}`);
|
||||
}
|
||||
const jsonData = await response.blob();
|
||||
|
||||
const formData = new FormData();
|
||||
const file = new File([jsonData], `${selectedDataset.en}.json`, { type: 'application/json' });
|
||||
formData.append('file', file);
|
||||
formData.append('questionType', selectedDataset.type);
|
||||
const tags = `[${selectedDataset.level}] ${selectedDataset.en}`;
|
||||
formData.append('tags', tags);
|
||||
|
||||
const importResponse = await fetch(`/api/projects/${projectId}/eval-datasets/import`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await importResponse.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
onSuccess?.(result.data);
|
||||
handleClose();
|
||||
} else {
|
||||
console.error(result.error);
|
||||
alert(result.error || t('evalDatasets.import.failed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
alert(error.message || t('evalDatasets.import.failed'));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
setSelectedDataset(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (downloading) return;
|
||||
setKeyword('');
|
||||
setSelectedDataset(null);
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<StyledDialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<StorageIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{t('evalDatasets.import.builtinTitle', '选择内置数据集')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={handleClose} disabled={downloading} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</StyledDialogTitle>
|
||||
|
||||
<DialogContent
|
||||
dividers
|
||||
sx={{
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '70vh',
|
||||
bgcolor: alpha(theme.palette.grey[50], 0.5)
|
||||
}}
|
||||
>
|
||||
{/* 搜索栏 */}
|
||||
<Box sx={{ p: 2, bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={t('evalDatasets.import.searchPlaceholder', '搜索数据集...')}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: 'text.disabled', fontSize: 20 }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { borderRadius: 2 }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 数据集列表 */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{downloading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} thickness={4} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
{t('evalDatasets.import.downloading', '下载并导入中...')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 1.5,
|
||||
alignContent: 'start'
|
||||
}}
|
||||
>
|
||||
{filteredDatasets.map((ds, index) => {
|
||||
const difficultyColor = ds.level === 'easy' ? 'success.main' : 'warning.main';
|
||||
const typeLabel = t(`eval.questionTypes.${ds.type}`, ds.type);
|
||||
const tooltipTitle = (
|
||||
<Box sx={{ display: 'flex', gap: 0.8, p: 0.5 }}>
|
||||
<Chip
|
||||
label={typeLabel}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: alpha('#fff', 0.15),
|
||||
color: '#fff',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#fff', 0.1),
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={ds.level.toUpperCase()}
|
||||
size="small"
|
||||
color={ds.level === 'easy' ? 'success' : 'warning'}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 800,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={tooltipTitle}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: 'rgba(33, 33, 33, 0.95)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
borderRadius: 1.5,
|
||||
padding: '4px 8px'
|
||||
}
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: 'rgba(33, 33, 33, 0.95)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderLeft: '4px solid',
|
||||
borderLeftColor: difficultyColor,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
bgcolor: 'background.paper',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 6px 16px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
borderColor: theme.palette.primary.main,
|
||||
'& .dataset-title': { color: 'primary.main' }
|
||||
}
|
||||
}}
|
||||
onClick={() => handleCardClick(ds)}
|
||||
>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
p: 1.5,
|
||||
height: '100%',
|
||||
minHeight: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
className="dataset-title"
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
color: 'text.primary',
|
||||
transition: 'color 0.2s',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{isZh ? ds.zh : ds.en}
|
||||
</Typography>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={confirmOpen}
|
||||
onClose={handleConfirmClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { borderRadius: 3 } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 700, pb: 1 }}>
|
||||
{t('evalDatasets.import.confirmImportTitle', '确认导入')}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pb: 1 }}>
|
||||
<DialogContentText sx={{ color: 'text.primary' }}>
|
||||
{selectedDataset &&
|
||||
t('evalDatasets.import.confirmImportMessage', {
|
||||
name: isZh ? selectedDataset.zh : selectedDataset.en
|
||||
})}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2.5, pt: 1.5 }}>
|
||||
<Button onClick={handleConfirmClose} color="inherit" sx={{ fontWeight: 600 }}>
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button onClick={handleImport} variant="contained" autoFocus sx={{ fontWeight: 600, px: 3 }}>
|
||||
{t('evalDatasets.import.import', '导入')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, Box, Typography, Chip, Checkbox, IconButton, Tooltip, Divider } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import ShortTextIcon from '@mui/icons-material/ShortText';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// 题型图标和颜色映射
|
||||
const QUESTION_TYPE_CONFIG = {
|
||||
true_false: {
|
||||
icon: CheckCircleIcon,
|
||||
color: 'success',
|
||||
bgColor: 'success.light'
|
||||
},
|
||||
single_choice: {
|
||||
icon: RadioButtonCheckedIcon,
|
||||
color: 'primary',
|
||||
bgColor: 'primary.light'
|
||||
},
|
||||
multiple_choice: {
|
||||
icon: CheckBoxIcon,
|
||||
color: 'secondary',
|
||||
bgColor: 'secondary.light'
|
||||
},
|
||||
short_answer: {
|
||||
icon: ShortTextIcon,
|
||||
color: 'warning',
|
||||
bgColor: 'warning.light'
|
||||
},
|
||||
open_ended: {
|
||||
icon: NotesIcon,
|
||||
color: 'info',
|
||||
bgColor: 'info.light'
|
||||
}
|
||||
};
|
||||
|
||||
export default function EvalDatasetCard({ item, selected, onSelect, onEdit, onDelete, projectId }) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const typeConfig = QUESTION_TYPE_CONFIG[item.questionType] || QUESTION_TYPE_CONFIG.short_answer;
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
// 解析选项
|
||||
const options = item.options
|
||||
? typeof item.options === 'string'
|
||||
? JSON.parse(item.options || '[]')
|
||||
: item.options
|
||||
: [];
|
||||
|
||||
// 解析答案
|
||||
const correctAnswer = item.correctAnswer;
|
||||
|
||||
const handleCardClick = e => {
|
||||
// 如果点击的是复选框或按钮,不跳转
|
||||
if (e.target.closest('.MuiCheckbox-root') || e.target.closest('.MuiIconButton-root')) {
|
||||
return;
|
||||
}
|
||||
router.push(`/projects/${projectId}/eval-datasets/${item.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
onClick={handleCardClick}
|
||||
sx={{
|
||||
height: 'fit-content',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
borderColor: selected ? theme.palette.primary.main : theme.palette.divider,
|
||||
bgcolor: selected ? alpha(theme.palette.primary.main, 0.04) : 'background.paper',
|
||||
borderRadius: 2,
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: `0 8px 24px ${alpha(theme.palette.common.black, 0.08)}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flex: 1, display: 'flex', flexDirection: 'column', p: 2.5 }}>
|
||||
{/* 头部:题型标签和操作 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={selected}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id);
|
||||
}}
|
||||
sx={{ p: 0.5, ml: -0.5 }}
|
||||
/>
|
||||
<Chip
|
||||
icon={<TypeIcon sx={{ fontSize: '16px !important' }} />}
|
||||
label={t(`eval.questionTypes.${item.questionType}`)}
|
||||
size="small"
|
||||
color={typeConfig.color}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
borderWidth: '1.5px',
|
||||
bgcolor: alpha(theme.palette[typeConfig.color].main, 0.05)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEdit(item);
|
||||
}}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'primary.main', bgcolor: alpha(theme.palette.primary.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'error.main', bgcolor: alpha(theme.palette.error.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 问题内容 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.6,
|
||||
color:
|
||||
item.questionType === 'true_false'
|
||||
? correctAnswer === '✅'
|
||||
? 'success.main'
|
||||
: 'error.main'
|
||||
: 'text.primary',
|
||||
display: 'inline'
|
||||
}}
|
||||
>
|
||||
{item.questionType === 'true_false' && correctAnswer} {item.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 选项列表(仅单选/多选显示) */}
|
||||
{(item.questionType === 'single_choice' || item.questionType === 'multiple_choice') && options.length > 0 && (
|
||||
<Box sx={{ mb: 2, flex: 1 }}>
|
||||
{(item.questionType === 'multiple_choice' ? options : options.slice(0, 4)).map((option, index) => {
|
||||
const optionLabel = String.fromCharCode(65 + index); // A, B, C, D
|
||||
// 解析多选题答案,支持多种格式:数组、JSON字符串、逗号分隔字符串
|
||||
const parseMultipleAnswers = answer => {
|
||||
if (Array.isArray(answer)) return answer;
|
||||
if (!answer) return [];
|
||||
// 尝试解析 JSON 数组
|
||||
if (answer.startsWith('[')) {
|
||||
try {
|
||||
return JSON.parse(answer);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// 逗号分隔字符串格式,如 "A,B,D"
|
||||
return answer.split(',').map(s => s.trim());
|
||||
};
|
||||
const isCorrect =
|
||||
item.questionType === 'multiple_choice'
|
||||
? parseMultipleAnswers(correctAnswer).includes(optionLabel)
|
||||
: correctAnswer === optionLabel;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
p: '4px 8px',
|
||||
borderRadius: 1,
|
||||
bgcolor: isCorrect ? alpha(theme.palette.success.main, 0.08) : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: isCorrect ? alpha(theme.palette.success.main, 0.3) : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: isCorrect ? 'success.main' : 'text.secondary',
|
||||
minWidth: 16
|
||||
}}
|
||||
>
|
||||
{optionLabel}.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: isCorrect ? 'success.dark' : 'text.secondary',
|
||||
fontSize: '0.875rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{item.questionType === 'single_choice' && options.length > 4 && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ pl: 1, mt: 0.5, display: 'block' }}>
|
||||
... +{options.length - 4} {t('eval.moreOptions')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 非选择题且非判断题答案 */}
|
||||
{item.questionType !== 'single_choice' &&
|
||||
item.questionType !== 'multiple_choice' &&
|
||||
item.questionType !== 'true_false' &&
|
||||
correctAnswer && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
mb: 2,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
{t('eval.answer')}:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 4,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{correctAnswer}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5, opacity: 0.6 }} />
|
||||
|
||||
{/* 底部元信息 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
{item.chunks ? (
|
||||
<Tooltip title={item.chunks.name || item.chunks.fileName}>
|
||||
<Chip
|
||||
label={item.chunks.name || item.chunks.fileName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
height: 22,
|
||||
maxWidth: 140,
|
||||
borderColor: 'divider',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Box />
|
||||
)}
|
||||
|
||||
{item.tags && (
|
||||
<Tooltip title={item.tags}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, overflow: 'hidden', maxWidth: 120 }}>
|
||||
{item.tags
|
||||
.split(/[,,]/)
|
||||
.slice(0, 2)
|
||||
.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
height: 22,
|
||||
bgcolor: alpha(theme.palette.info.main, 0.08),
|
||||
color: 'info.dark',
|
||||
maxWidth: 80
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{item.tags.split(/[,,]/).length > 2 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11, alignSelf: 'center' }}>
|
||||
+{item.tags.split(/[,,]/).length - 2}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Divider, Typography, IconButton, Paper, Tooltip } 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 { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function EvalDatasetHeader({ projectId, onNavigate, 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}/eval-datasets`)}
|
||||
>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="h6">{t('eval.detail')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<IconButton onClick={() => onNavigate('prev')} title={t('common.prev')}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onNavigate('next')} title={t('common.next')}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Chip,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function EvalDatasetList({ items, selectedIds, onSelect, onSelectAll, onEdit, onDelete, onView }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isAllSelected = items.length > 0 && selectedIds.length === items.length;
|
||||
const isIndeterminate = selectedIds.length > 0 && selectedIds.length < items.length;
|
||||
|
||||
// 题型颜色映射
|
||||
const getTypeColor = type => {
|
||||
const colors = {
|
||||
true_false: 'success',
|
||||
single_choice: 'primary',
|
||||
multiple_choice: 'secondary',
|
||||
short_answer: 'warning',
|
||||
open_ended: 'info'
|
||||
};
|
||||
return colors[type] || 'default';
|
||||
};
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = item => {
|
||||
const { questionType, correctAnswer, options } = item;
|
||||
|
||||
if (questionType === 'true_false') {
|
||||
return correctAnswer;
|
||||
}
|
||||
|
||||
if (questionType === 'single_choice' || questionType === 'multiple_choice') {
|
||||
return correctAnswer;
|
||||
}
|
||||
|
||||
// 非选择题,截断显示
|
||||
if (correctAnswer && correctAnswer.length > 50) {
|
||||
return correctAnswer.substring(0, 50) + '...';
|
||||
}
|
||||
return correctAnswer || '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox indeterminate={isIndeterminate} checked={isAllSelected} onChange={onSelectAll} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>{t('eval.questionType')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 300 }}>{t('eval.question')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 150 }}>{t('eval.answer')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 120 }}>{t('eval.sourceChunk')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, width: 120 }} align="center">
|
||||
{t('common.actions')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map(item => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
hover
|
||||
selected={selectedIds.includes(item.id)}
|
||||
sx={{ '&:last-child td': { border: 0 } }}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox checked={selectedIds.includes(item.id)} onChange={() => onSelect(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={t(`eval.questionTypes.${item.questionType}`)}
|
||||
size="small"
|
||||
color={getTypeColor(item.questionType)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{item.question}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>
|
||||
{formatAnswer(item)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.chunks ? (
|
||||
<Chip
|
||||
label={item.chunks.name || item.chunks.fileName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ maxWidth: 150 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 0.5 }}>
|
||||
<Tooltip title={t('datasets.viewDetails')}>
|
||||
<IconButton size="small" onClick={() => onView(item)}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(item.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">{t('common.noData')}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, Button, TextField, IconButton, Paper } from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function EvalEditableField({
|
||||
label,
|
||||
value,
|
||||
multiline = true,
|
||||
onSave,
|
||||
placeholder,
|
||||
renderPreview, // Optional custom preview renderer
|
||||
renderEditor // Optional custom editor renderer (currentValue, onChange) => ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditValue(value || '');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
await onSave(editValue);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ fontWeight: 600, mr: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{!editing && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleStartEdit}
|
||||
sx={{
|
||||
color: 'text.disabled',
|
||||
'&:hover': { color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{editing ? (
|
||||
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'background.default', borderRadius: 2 }}>
|
||||
{renderEditor && renderEditor(editValue, setEditValue) ? (
|
||||
<Box sx={{ mb: 2 }}>{renderEditor(editValue, setEditValue)}</Box>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline={multiline}
|
||||
minRows={multiline ? 3 : 1}
|
||||
maxRows={15}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button size="small" startIcon={<CancelIcon />} onClick={handleCancel} color="inherit">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="small" variant="contained" startIcon={<SaveIcon />} onClick={handleSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
minHeight: 40,
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.02),
|
||||
boxShadow: `0 0 0 1px ${theme.palette.primary.main}`
|
||||
}
|
||||
}}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{renderPreview ? (
|
||||
renderPreview(value)
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: value ? 'text.primary' : 'text.disabled',
|
||||
fontStyle: value ? 'normal' : 'italic',
|
||||
lineHeight: 1.6
|
||||
}}
|
||||
>
|
||||
{value || t('common.noData')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
ToggleButton,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Autocomplete,
|
||||
TextField,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteOutline'; // 使用 Outline 版本更精致
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircleOutline'; // 统一使用 Outline 风格图标
|
||||
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBoxOutlineBlank'; // 或者 CheckBox
|
||||
import ShortTextIcon from '@mui/icons-material/ShortText';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
ToolbarContainer,
|
||||
FilterGroup,
|
||||
FilterButton,
|
||||
SearchWrapper,
|
||||
StyledInputBase,
|
||||
ActionGroup,
|
||||
ActionButton,
|
||||
DeleteActionButton,
|
||||
StyledToggleButtonGroup
|
||||
} from './EvalToolbar.styles';
|
||||
|
||||
const STATS_CONFIG = [
|
||||
{ key: 'true_false', icon: CheckCircleIcon, color: 'success' },
|
||||
{ key: 'single_choice', icon: RadioButtonCheckedIcon, color: 'primary' },
|
||||
{ key: 'multiple_choice', icon: CheckBoxIcon, color: 'secondary' },
|
||||
{ key: 'short_answer', icon: ShortTextIcon, color: 'warning' },
|
||||
{ key: 'open_ended', icon: NotesIcon, color: 'info' }
|
||||
];
|
||||
|
||||
export default function EvalToolbar({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
selectedCount,
|
||||
onDeleteSelected,
|
||||
stats,
|
||||
questionType,
|
||||
onTypeChange,
|
||||
tags,
|
||||
onTagsChange,
|
||||
onImport,
|
||||
onBuiltinImport,
|
||||
onExport
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const [importAnchorEl, setImportAnchorEl] = useState(null);
|
||||
|
||||
const handleImportClick = event => {
|
||||
setImportAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleImportClose = () => {
|
||||
setImportAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleCustomImport = () => {
|
||||
handleImportClose();
|
||||
onImport?.();
|
||||
};
|
||||
|
||||
const handleBuiltinImport = () => {
|
||||
handleImportClose();
|
||||
onBuiltinImport?.();
|
||||
};
|
||||
|
||||
const tagOptions = stats?.byTag
|
||||
? Object.keys(stats.byTag).map(tag => ({
|
||||
label: tag,
|
||||
count: stats.byTag[tag]
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ToolbarContainer elevation={0} variant="outlined">
|
||||
{/* 顶部:题型统计筛选 */}
|
||||
<FilterGroup>
|
||||
{stats &&
|
||||
STATS_CONFIG.map(({ key, icon: Icon, color }) => {
|
||||
const count = stats.byType?.[key] || 0;
|
||||
const isActive = questionType === key;
|
||||
|
||||
return (
|
||||
<FilterButton
|
||||
key={key}
|
||||
startIcon={<Icon sx={{ fontSize: 18 }} />}
|
||||
active={isActive}
|
||||
colorType={color}
|
||||
onClick={() => onTypeChange(isActive ? '' : key)}
|
||||
>
|
||||
{t(`eval.questionTypes.${key}`)}
|
||||
<Box component="span" sx={{ ml: 0.8, opacity: 0.9, fontSize: '0.8em' }}>
|
||||
({count})
|
||||
</Box>
|
||||
</FilterButton>
|
||||
);
|
||||
})}
|
||||
</FilterGroup>
|
||||
|
||||
<Divider sx={{ opacity: 0.6 }} />
|
||||
|
||||
{/* 底部:筛选和操作 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
{/* 左侧:筛选器组 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1, minWidth: 300 }}>
|
||||
{/* 搜索框 */}
|
||||
<SearchWrapper>
|
||||
<IconButton sx={{ p: '8px' }} aria-label="search" disabled>
|
||||
<SearchIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
</IconButton>
|
||||
<StyledInputBase
|
||||
placeholder={t('eval.searchPlaceholder', '搜索题目内容...')}
|
||||
value={keyword}
|
||||
onChange={e => onKeywordChange(e.target.value)}
|
||||
/>
|
||||
</SearchWrapper>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={tagOptions}
|
||||
getOptionLabel={option => `${option.label} (${option.count})`}
|
||||
value={tagOptions.filter(o => tags.includes(o.label))}
|
||||
onChange={(e, newValue) => onTagsChange(newValue.map(v => v.label))}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={tags.length === 0 ? t('eval.tags', '标签') : ''}
|
||||
size="small"
|
||||
sx={{
|
||||
width: 280,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: 'background.paper',
|
||||
minHeight: 42,
|
||||
fieldset: {
|
||||
borderColor: theme.palette.divider
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: theme.palette.text.secondary
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: 1,
|
||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
'& .MuiAutocomplete-tag': {
|
||||
height: 24,
|
||||
borderRadius: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:操作按钮组 */}
|
||||
<ActionGroup>
|
||||
{/* 导入按钮下拉菜单 */}
|
||||
<ActionButton
|
||||
variant="outlined"
|
||||
startIcon={<UploadFileIcon />}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
onClick={handleImportClick}
|
||||
>
|
||||
{t('common.import', '导入')}
|
||||
</ActionButton>
|
||||
<Menu
|
||||
anchorEl={importAnchorEl}
|
||||
open={Boolean(importAnchorEl)}
|
||||
onClose={handleImportClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleCustomImport} disableRipple>
|
||||
<UploadFileIcon fontSize="small" sx={{ mr: 1.5, color: 'text.secondary' }} />
|
||||
{t('evalDatasets.import.custom', '导入自定义数据集')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleBuiltinImport} disableRipple>
|
||||
<StorageIcon fontSize="small" sx={{ mr: 1.5, color: 'text.secondary' }} />
|
||||
{t('evalDatasets.import.builtin', '导入内置数据集')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<ActionButton variant="outlined" startIcon={<FileDownloadIcon />} onClick={onExport}>
|
||||
{t('common.export', '导出')}
|
||||
</ActionButton>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<DeleteActionButton variant="soft" startIcon={<DeleteIcon />} onClick={onDeleteSelected}>
|
||||
{t('eval.deleteSelectedCount', `删除选中 (${selectedCount})`, { count: selectedCount })}
|
||||
</DeleteActionButton>
|
||||
)}
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', mx: 0.5 }} />
|
||||
|
||||
<StyledToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(e, value) => value && onViewModeChange(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="card" aria-label="card view">
|
||||
<Tooltip title={t('eval.cardView', '卡片视图')}>
|
||||
<ViewModuleIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="list" aria-label="list view">
|
||||
<Tooltip title={t('eval.listView', '列表视图')}>
|
||||
<ViewListIcon fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
</StyledToggleButtonGroup>
|
||||
</ActionGroup>
|
||||
</Box>
|
||||
</ToolbarContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import { Box, Paper, Button, ToggleButton, ToggleButtonGroup, InputBase } from '@mui/material';
|
||||
|
||||
export const ToolbarContainer = styled(Paper)(({ theme }) => ({
|
||||
padding: theme.spacing(2, 2.5),
|
||||
marginBottom: theme.spacing(3),
|
||||
borderRadius: theme.shape.borderRadius * 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.03)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2)
|
||||
}));
|
||||
|
||||
export const FilterGroup = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1.5),
|
||||
flexWrap: 'wrap'
|
||||
}));
|
||||
|
||||
export const FilterButton = styled(Button, {
|
||||
shouldForwardProp: prop => prop !== 'active' && prop !== 'colorType'
|
||||
})(({ theme, active, colorType }) => {
|
||||
const colorMap = {
|
||||
success: theme.palette.success,
|
||||
primary: theme.palette.primary,
|
||||
secondary: theme.palette.secondary,
|
||||
warning: theme.palette.warning,
|
||||
info: theme.palette.info
|
||||
};
|
||||
const mainColor = colorMap[colorType] || theme.palette.primary;
|
||||
|
||||
return {
|
||||
padding: theme.spacing(0.75, 2),
|
||||
borderRadius: theme.shape.borderRadius * 5, // Pill shape
|
||||
border: '1px solid',
|
||||
borderColor: active ? mainColor.main : theme.palette.divider,
|
||||
backgroundColor: active ? alpha(mainColor.main, 0.1) : 'transparent',
|
||||
color: active ? mainColor.main : theme.palette.text.secondary,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: active ? 600 : 400,
|
||||
minWidth: 'auto',
|
||||
textTransform: 'none',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
backgroundColor: active ? alpha(mainColor.main, 0.15) : alpha(theme.palette.text.primary, 0.04),
|
||||
borderColor: active ? mainColor.main : theme.palette.text.secondary,
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
'& .MuiButton-startIcon': {
|
||||
marginRight: theme.spacing(0.8),
|
||||
color: active ? mainColor.main : theme.palette.text.disabled,
|
||||
width: 18,
|
||||
height: 18
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const SearchWrapper = styled(Paper)(({ theme }) => ({
|
||||
padding: '2px 4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 280,
|
||||
height: 42,
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
boxShadow: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.text.secondary,
|
||||
backgroundColor: alpha(theme.palette.action.hover, 0.05)
|
||||
},
|
||||
'&:focus-within': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
backgroundColor: theme.palette.background.paper
|
||||
}
|
||||
}));
|
||||
|
||||
export const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(1),
|
||||
flex: 1,
|
||||
fontSize: '0.875rem',
|
||||
'& input': {
|
||||
'&::placeholder': {
|
||||
color: theme.palette.text.disabled,
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export const ActionGroup = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1.5)
|
||||
}));
|
||||
|
||||
export const ActionButton = styled(Button)(({ theme }) => ({
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
height: 40,
|
||||
paddingLeft: theme.spacing(3),
|
||||
paddingRight: theme.spacing(3),
|
||||
borderColor: theme.palette.divider,
|
||||
color: theme.palette.text.secondary,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.text.primary,
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.action.hover
|
||||
}
|
||||
}));
|
||||
|
||||
export const DeleteActionButton = styled(Button)(({ theme }) => ({
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
height: 40,
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.1),
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.2)
|
||||
}
|
||||
}));
|
||||
|
||||
export const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({
|
||||
height: 40,
|
||||
backgroundColor: theme.palette.action.hover, // Slightly darker than paper
|
||||
padding: 4,
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
border: 'none',
|
||||
gap: 4,
|
||||
'& .MuiToggleButton-root': {
|
||||
border: 'none',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
width: 36,
|
||||
color: theme.palette.text.secondary,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.primary.main,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.background.paper
|
||||
}
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0,0,0,0.04)'
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: 'true_false', labelKey: 'eval.questionTypes.true_false' },
|
||||
{ value: 'single_choice', labelKey: 'eval.questionTypes.single_choice' },
|
||||
{ value: 'multiple_choice', labelKey: 'eval.questionTypes.multiple_choice' },
|
||||
{ value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' },
|
||||
{ value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' }
|
||||
];
|
||||
|
||||
const EXPORT_FORMATS = [
|
||||
{ value: 'json', label: 'JSON', description: 'evalDatasets.export.jsonDesc' },
|
||||
{ value: 'jsonl', label: 'JSONL', description: 'evalDatasets.export.jsonlDesc' },
|
||||
{ value: 'csv', label: 'CSV', description: 'evalDatasets.export.csvDesc' }
|
||||
];
|
||||
|
||||
export default function ExportEvalDialog({
|
||||
open,
|
||||
onClose,
|
||||
exporting,
|
||||
error,
|
||||
format,
|
||||
setFormat,
|
||||
questionTypes,
|
||||
setQuestionTypes,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
keyword,
|
||||
setKeyword,
|
||||
previewTotal,
|
||||
previewLoading,
|
||||
availableTags,
|
||||
resetFilters,
|
||||
onExport
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasFilters = questionTypes.length > 0 || selectedTags.length > 0 || keyword;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FileDownloadIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{t('evalDatasets.export.title', '导出评估数据集')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={onClose} disabled={exporting} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => {}}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 导出格式选择 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
{t('evalDatasets.export.formatLabel', '导出格式')}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={format}
|
||||
exclusive
|
||||
onChange={(e, newFormat) => newFormat && setFormat(newFormat)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{EXPORT_FORMATS.map(f => (
|
||||
<ToggleButton key={f.value} value={f.value} sx={{ flex: 1 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{f.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(f.description, f.label)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<FilterAltIcon sx={{ mr: 1, color: 'primary.main', fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
{t('evalDatasets.export.filterLabel', '筛选条件')}
|
||||
</Typography>
|
||||
{hasFilters && (
|
||||
<Button size="small" startIcon={<ClearIcon />} onClick={resetFilters}>
|
||||
{t('evalTasks.clearFilter', '清空')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* 关键字搜索 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('evalTasks.searchKeyword', '搜索关键字')}
|
||||
placeholder={t('evalTasks.searchPlaceholder', '搜索题目内容...')}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* 题型和标签筛选 */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 题型筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('evalTasks.filterByTypeLabel', '题型筛选')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={questionTypes}
|
||||
onChange={e => setQuestionTypes(e.target.value)}
|
||||
input={<OutlinedInput label={t('evalTasks.filterByTypeLabel', '题型筛选')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={t(`eval.questionTypes.${value}`)} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
<Checkbox checked={questionTypes.includes(type.value)} />
|
||||
<ListItemText primary={t(type.labelKey)} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('evalTasks.filterByTagLabel', '标签筛选')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={e => setSelectedTags(e.target.value)}
|
||||
input={<OutlinedInput label={t('evalTasks.filterByTagLabel', '标签筛选')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: { maxHeight: 300 }
|
||||
}
|
||||
}}
|
||||
disabled={availableTags.length === 0}
|
||||
>
|
||||
{availableTags.length === 0 ? (
|
||||
<MenuItem disabled>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalDatasets.export.noTagsAvailable', '暂无可用标签')}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
) : (
|
||||
availableTags.map(tag => (
|
||||
<MenuItem key={tag} value={tag}>
|
||||
<Checkbox checked={selectedTags.includes(tag)} />
|
||||
<ListItemText primary={tag} />
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 导出预览 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalDatasets.export.previewLabel', '将导出数据:')}
|
||||
</Typography>
|
||||
{previewLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{previewTotal} {t('evalDatasets.export.records', '条记录')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{previewTotal > 1000 && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
{t('evalDatasets.export.largeDataHint', '数据量较大,将采用流式导出,请耐心等待')}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onClose} disabled={exporting} color="inherit">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onExport}
|
||||
variant="contained"
|
||||
disabled={exporting || previewLoading || previewTotal === 0}
|
||||
startIcon={exporting ? <CircularProgress size={16} /> : <FileDownloadIcon />}
|
||||
>
|
||||
{exporting ? t('evalDatasets.export.exporting', '导出中...') : t('evalDatasets.export.exportBtn', '导出')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
IconButton,
|
||||
Radio
|
||||
} from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import {
|
||||
QUESTION_TYPES,
|
||||
FORMAT_PREVIEW,
|
||||
getJsonTemplateData,
|
||||
getExcelTemplateData,
|
||||
getColumnWidths
|
||||
} from '../constants';
|
||||
import {
|
||||
StyledDialogTitle,
|
||||
UploadBox,
|
||||
PreviewPaper,
|
||||
CodeBlock,
|
||||
ErrorContainer,
|
||||
TypeRadioGroup,
|
||||
TypeFormControlLabel
|
||||
} from './ImportDialog.styles';
|
||||
|
||||
export default function ImportDialog({ open, onClose, projectId, onSuccess }) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const [questionType, setQuestionType] = useState('open_ended');
|
||||
const [tags, setTags] = useState('');
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [errorDetails, setErrorDetails] = useState([]);
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = e => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile) {
|
||||
const ext = selectedFile.name.split('.').pop().toLowerCase();
|
||||
if (!['json', 'xls', 'xlsx'].includes(ext)) {
|
||||
setError(t('evalDatasets.import.invalidFileType', '不支持的文件格式,请上传 json、xls 或 xlsx 文件'));
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
setError(null);
|
||||
setErrorDetails([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载模板
|
||||
const handleDownloadTemplate = format => {
|
||||
if (!questionType) {
|
||||
setError(t('evalDatasets.import.selectTypeFirst', '请先选择题型'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
// JSON 模板动态生成并下载
|
||||
const templateData = getJsonTemplateData(questionType);
|
||||
const jsonContent = JSON.stringify(templateData, null, 2);
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `eval-dataset-template-${questionType}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// Excel 模板动态生成
|
||||
const templateData = getExcelTemplateData(questionType);
|
||||
const worksheet = XLSX.utils.json_to_sheet(templateData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Template');
|
||||
|
||||
// 设置列宽
|
||||
const colWidths = getColumnWidths(questionType);
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
// 下载文件
|
||||
XLSX.writeFile(workbook, `eval-dataset-template-${questionType}.xlsx`);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交导入
|
||||
const handleSubmit = async () => {
|
||||
if (!questionType) {
|
||||
setError(t('evalDatasets.import.selectTypeFirst', '请先选择题型'));
|
||||
return;
|
||||
}
|
||||
if (!file) {
|
||||
setError(t('evalDatasets.import.selectFile', '请选择要导入的文件'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setErrorDetails([]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('questionType', questionType);
|
||||
formData.append('tags', tags);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/import`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
onSuccess?.(result.data);
|
||||
handleClose();
|
||||
} else {
|
||||
setError(result.error || result.message);
|
||||
if (result.details) {
|
||||
setErrorDetails(result.details);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || t('evalDatasets.import.failed', '导入失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
if (loading) return;
|
||||
setQuestionType('open_ended');
|
||||
setTags('');
|
||||
setFile(null);
|
||||
setError(null);
|
||||
setErrorDetails([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 获取当前题型的格式预览
|
||||
const formatPreview = questionType ? FORMAT_PREVIEW[questionType] : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<StyledDialogTitle>
|
||||
{t('evalDatasets.import.title', '导入评估数据集')}
|
||||
<IconButton onClick={handleClose} disabled={loading} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</StyledDialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
{errorDetails.length > 0 && (
|
||||
<ErrorContainer>
|
||||
{errorDetails.map((detail, index) => (
|
||||
<Box key={index} className="item">
|
||||
{detail}
|
||||
</Box>
|
||||
))}
|
||||
{errorDetails.length < 10 && (
|
||||
<Box sx={{ mt: 0.5, color: 'text.secondary', ml: 2 }}>
|
||||
{t('evalDatasets.import.showingErrors', '显示前 {{count}} 条错误', { count: errorDetails.length })}
|
||||
</Box>
|
||||
)}
|
||||
</ErrorContainer>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 题型选择 - 使用封装好的样式组件 */}
|
||||
<Box sx={{ mb: 3, mt: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600, color: 'text.primary' }}>
|
||||
{t('evalDatasets.import.questionType', '选择题型')}
|
||||
</Typography>
|
||||
<TypeRadioGroup value={questionType} onChange={e => setQuestionType(e.target.value)}>
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<TypeFormControlLabel
|
||||
key={type.value}
|
||||
value={type.value}
|
||||
checked={questionType === type.value}
|
||||
control={<Radio size="small" />}
|
||||
label={t(type.label, type.labelZh)}
|
||||
/>
|
||||
))}
|
||||
</TypeRadioGroup>
|
||||
</Box>
|
||||
|
||||
{/* 数据格式预览 */}
|
||||
{formatPreview && (
|
||||
<PreviewPaper variant="outlined">
|
||||
<Typography variant="subtitle2" className="title">
|
||||
{t('evalDatasets.import.formatPreview', '数据格式预览')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
{formatPreview.fields.map(field => (
|
||||
<Chip key={field} label={field} size="small" variant="outlined" color="primary" />
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{formatPreview.description}
|
||||
</Typography>
|
||||
<CodeBlock>
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify(formatPreview.example, null, 2)}</pre>
|
||||
</CodeBlock>
|
||||
|
||||
{/* 下载模板按钮 */}
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => handleDownloadTemplate('json')}
|
||||
>
|
||||
JSON {t('evalDatasets.import.template', '模板')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => handleDownloadTemplate('xlsx')}
|
||||
>
|
||||
Excel {t('evalDatasets.import.template', '模板')}
|
||||
</Button>
|
||||
</Box>
|
||||
</PreviewPaper>
|
||||
)}
|
||||
|
||||
{/* 文件上传 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".json,.xls,.xlsx"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<UploadBox active={false} hasFile={!!file} onClick={() => fileInputRef.current?.click()}>
|
||||
{file ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<InsertDriveFileIcon color="primary" sx={{ fontSize: 40 }} />
|
||||
<Typography color="primary" variant="h6">
|
||||
{file.name}
|
||||
</Typography>
|
||||
<Chip label={`${(file.size / 1024).toFixed(1)} KB`} size="small" color="primary" variant="soft" />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{t('common.clickToReplace', '点击更换文件')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('evalDatasets.import.dropOrClick', '点击或拖拽文件到此处')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalDatasets.import.supportedFormats', '支持 JSON、XLS、XLSX 格式')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</UploadBox>
|
||||
</Box>
|
||||
|
||||
{/* 标签输入 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('evalDatasets.import.tags', '标签(可选)')}
|
||||
placeholder={t('evalDatasets.import.tagsPlaceholder', '为导入的数据添加标签,多个标签用逗号分隔')}
|
||||
value={tags}
|
||||
onChange={e => setTags(e.target.value)}
|
||||
disabled={loading}
|
||||
helperText={t('evalDatasets.import.tagsHelp', '导入的所有数据将打上这些标签')}
|
||||
InputProps={{
|
||||
startAdornment: tags ? <Box sx={{ mr: 1, color: 'text.secondary' }}>#</Box> : null
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={handleClose} disabled={loading} size="large">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !questionType || !file}
|
||||
size="large"
|
||||
disableElevation
|
||||
>
|
||||
{loading ? t('evalDatasets.import.importing', '导入中...') : t('evalDatasets.import.import', '导入')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import { Box, Paper, DialogTitle as MuiDialogTitle, RadioGroup, FormControlLabel } from '@mui/material';
|
||||
|
||||
export const StyledDialogTitle = styled(MuiDialogTitle)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(2, 3),
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
'& .MuiTypography-root': {
|
||||
fontWeight: 600,
|
||||
fontSize: '1.1rem'
|
||||
}
|
||||
}));
|
||||
|
||||
export const TypeRadioGroup = styled(RadioGroup)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(2)
|
||||
}));
|
||||
|
||||
export const TypeFormControlLabel = styled(FormControlLabel, {
|
||||
shouldForwardProp: prop => prop !== 'checked'
|
||||
})(({ theme, checked }) => ({
|
||||
margin: 0,
|
||||
padding: '4px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: checked ? theme.palette.primary.main : theme.palette.divider,
|
||||
backgroundColor: checked ? alpha(theme.palette.primary.main, 0.05) : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
backgroundColor: checked ? alpha(theme.palette.primary.main, 0.08) : theme.palette.action.hover
|
||||
},
|
||||
'& .MuiTypography-root': {
|
||||
fontSize: '0.875rem',
|
||||
color: checked ? theme.palette.primary.main : theme.palette.text.primary,
|
||||
fontWeight: checked ? 600 : 400
|
||||
},
|
||||
'& .MuiRadio-root': {
|
||||
padding: '4px',
|
||||
color: checked ? theme.palette.primary.main : theme.palette.text.secondary
|
||||
}
|
||||
}));
|
||||
|
||||
export const UploadBox = styled(Box, {
|
||||
shouldForwardProp: prop => prop !== 'active' && prop !== 'hasFile'
|
||||
})(({ theme, active, hasFile }) => ({
|
||||
border: '2px dashed',
|
||||
borderColor: active ? theme.palette.primary.main : theme.palette.grey[300],
|
||||
borderRadius: theme.shape.borderRadius * 2,
|
||||
padding: theme.spacing(4),
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: active
|
||||
? alpha(theme.palette.primary.main, 0.05)
|
||||
: hasFile
|
||||
? alpha(theme.palette.primary.main, 0.05)
|
||||
: 'transparent',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.02),
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
},
|
||||
'& svg': {
|
||||
fontSize: 48,
|
||||
marginBottom: theme.spacing(1),
|
||||
color: active ? theme.palette.primary.main : theme.palette.grey[400],
|
||||
transition: 'color 0.3s ease'
|
||||
}
|
||||
}));
|
||||
|
||||
export const PreviewPaper = styled(Paper)(({ theme }) => ({
|
||||
padding: theme.spacing(2.5),
|
||||
marginBottom: theme.spacing(3),
|
||||
backgroundColor: theme.palette.grey[50],
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadius * 1.5,
|
||||
'& .title': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1.5),
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 600
|
||||
}
|
||||
}));
|
||||
|
||||
export const CodeBlock = styled(Box)(({ theme }) => ({
|
||||
backgroundColor: '#1e1e1e', // Dark theme for code
|
||||
color: '#d4d4d4',
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
fontFamily: '"Fira Code", "Roboto Mono", monospace',
|
||||
fontSize: '0.85rem',
|
||||
overflow: 'auto',
|
||||
maxHeight: 300,
|
||||
'&::-webkit-scrollbar': {
|
||||
height: 8,
|
||||
width: 8
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: '#2d2d2d'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: '#555',
|
||||
borderRadius: 4
|
||||
}
|
||||
}));
|
||||
|
||||
export const ErrorContainer = styled(Box)(({ theme }) => ({
|
||||
marginTop: theme.spacing(1),
|
||||
fontSize: '0.85rem',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
'& .item': {
|
||||
padding: theme.spacing(0.5, 0),
|
||||
color: theme.palette.error.main,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
'&::before': {
|
||||
content: '"•"',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export const TagInputWrapper = styled(Box)(({ theme }) => ({
|
||||
// Custom styles for tag input area if needed
|
||||
}));
|
||||
@@ -0,0 +1,679 @@
|
||||
export const QUESTION_TYPES = [
|
||||
{ value: 'true_false', label: 'eval.questionTypes.true_false', labelZh: '判断题' },
|
||||
{ value: 'single_choice', label: 'eval.questionTypes.single_choice', labelZh: '单选题' },
|
||||
{ value: 'multiple_choice', label: 'eval.questionTypes.multiple_choice', labelZh: '多选题' },
|
||||
{ value: 'short_answer', label: 'eval.questionTypes.short_answer', labelZh: '短答案题' },
|
||||
{ value: 'open_ended', label: 'eval.questionTypes.open_ended', labelZh: '开放式问题' }
|
||||
];
|
||||
|
||||
export const FORMAT_PREVIEW = {
|
||||
true_false: {
|
||||
fields: ['question', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Artificial Intelligence is a branch of computer science',
|
||||
correctAnswer: '✅ or ❌'
|
||||
},
|
||||
description: 'correctAnswer must be "✅" (correct) or "❌" (incorrect)'
|
||||
},
|
||||
single_choice: {
|
||||
fields: ['question', 'options', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Which of the following is a core feature of deep learning?',
|
||||
options: '["Option A", "Option B", "Option C", "Option D"]',
|
||||
correctAnswer: 'B'
|
||||
},
|
||||
description: 'options is an array of options, correctAnswer is the letter of the correct option (A/B/C/D)'
|
||||
},
|
||||
multiple_choice: {
|
||||
fields: ['question', 'options', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Which of the following are commonly used deep learning frameworks?',
|
||||
options: '["TensorFlow", "PyTorch", "Excel", "Keras"]',
|
||||
correctAnswer: '["A", "B", "D"]'
|
||||
},
|
||||
description: 'options is an array of options, correctAnswer is an array of correct option letters'
|
||||
},
|
||||
short_answer: {
|
||||
fields: ['question', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'What is the typical model structure used in deep learning?',
|
||||
correctAnswer: 'Neural Network'
|
||||
},
|
||||
description: 'correctAnswer is a short standard answer'
|
||||
},
|
||||
open_ended: {
|
||||
fields: ['question', 'correctAnswer'],
|
||||
example: {
|
||||
question: 'Analyze the main reasons for the success of deep learning in computer vision.',
|
||||
correctAnswer: 'Reference answer content...'
|
||||
},
|
||||
description: 'correctAnswer is a reference answer (can be long)'
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 JSON 模板数据
|
||||
export const getJsonTemplateData = type => {
|
||||
switch (type) {
|
||||
case 'true_false':
|
||||
return [
|
||||
{ question: 'Artificial Intelligence is a branch of computer science', correctAnswer: '✅' },
|
||||
{ question: 'Deep learning does not require large amounts of data for training', correctAnswer: '❌' }
|
||||
];
|
||||
case 'single_choice':
|
||||
return [
|
||||
{
|
||||
question: 'What is the core feature of deep learning?',
|
||||
options: [
|
||||
'Requires manual feature engineering',
|
||||
'Automatic feature learning',
|
||||
'Only handles structured data',
|
||||
'Does not need large amounts of data'
|
||||
],
|
||||
correctAnswer: 'B'
|
||||
},
|
||||
{
|
||||
question: 'Which of the following is a commonly used deep learning framework?',
|
||||
options: ['Excel', 'Word', 'TensorFlow', 'PowerPoint'],
|
||||
correctAnswer: 'C'
|
||||
}
|
||||
];
|
||||
case 'multiple_choice':
|
||||
return [
|
||||
{
|
||||
question: 'Which of the following are commonly used deep learning frameworks?',
|
||||
options: ['TensorFlow', 'PyTorch', 'Excel', 'Keras', 'Word'],
|
||||
correctAnswer: ['A', 'B', 'D']
|
||||
},
|
||||
{
|
||||
question: 'Which of the following are main types of machine learning?',
|
||||
options: ['Supervised Learning', 'Unsupervised Learning', 'Reinforcement Learning', 'Manual Learning'],
|
||||
correctAnswer: ['A', 'B', 'C']
|
||||
}
|
||||
];
|
||||
case 'short_answer':
|
||||
return [
|
||||
{ question: 'What is the typical model structure used in deep learning?', correctAnswer: 'Neural Network' },
|
||||
{ question: 'What is the maximum sample size mentioned in the text?', correctAnswer: '1000' }
|
||||
];
|
||||
case 'open_ended':
|
||||
return [
|
||||
{
|
||||
question: 'Analyze the main reasons for the success of deep learning in computer vision.',
|
||||
correctAnswer:
|
||||
'The success of deep learning in computer vision can be explained from three dimensions: models, data, and computing power...'
|
||||
},
|
||||
{
|
||||
question: 'Explain the overfitting problem in machine learning and its solutions.',
|
||||
correctAnswer:
|
||||
'Overfitting refers to the phenomenon where a model performs well on training data but poorly on new data...'
|
||||
}
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 Excel 模板数据
|
||||
export const getExcelTemplateData = type => {
|
||||
switch (type) {
|
||||
case 'true_false':
|
||||
return [
|
||||
{ question: 'Artificial Intelligence is a branch of computer science', correctAnswer: '✅' },
|
||||
{ question: 'Deep learning does not require large amounts of data for training', correctAnswer: '❌' }
|
||||
];
|
||||
case 'single_choice':
|
||||
return [
|
||||
{
|
||||
question: 'What is the core feature of deep learning?',
|
||||
options: `["Requires manual feature engineering", "Automatic feature learning", "Only handles structured data", "Does not need large amounts of data"]`,
|
||||
correctAnswer: 'B'
|
||||
},
|
||||
{
|
||||
question: 'Which of the following is a commonly used deep learning framework?',
|
||||
options: `["Excel", "Word", "TensorFlow", "PowerPoint"]`,
|
||||
correctAnswer: 'C'
|
||||
}
|
||||
];
|
||||
case 'multiple_choice':
|
||||
return [
|
||||
{
|
||||
question: 'Which of the following are commonly used deep learning frameworks?',
|
||||
options: `["TensorFlow", "PyTorch", "Excel", "Keras", "Word"]`,
|
||||
correctAnswer: `["A", "B", "D"]`
|
||||
},
|
||||
{
|
||||
question: 'Which of the following are main types of machine learning?',
|
||||
options: `["Supervised Learning", "Unsupervised Learning", "Reinforcement Learning", "Manual Learning"]`,
|
||||
correctAnswer: `["A", "B", "C"]`
|
||||
}
|
||||
];
|
||||
case 'short_answer':
|
||||
return [
|
||||
{ question: 'What is the typical model structure used in deep learning?', correctAnswer: 'Neural Network' },
|
||||
{ question: 'What is the maximum sample size mentioned in the text?', correctAnswer: '1000' }
|
||||
];
|
||||
case 'open_ended':
|
||||
return [
|
||||
{
|
||||
question: 'Analyze the main reasons for the success of deep learning in computer vision.',
|
||||
correctAnswer:
|
||||
'The success of deep learning in computer vision can be explained from three dimensions: models, data, and computing power...'
|
||||
},
|
||||
{
|
||||
question: 'Explain the overfitting problem in machine learning and its solutions.',
|
||||
correctAnswer:
|
||||
'Overfitting refers to the phenomenon where a model performs well on training data but poorly on new data...'
|
||||
}
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取列宽配置
|
||||
export const getColumnWidths = type => {
|
||||
if (type === 'single_choice' || type === 'multiple_choice') {
|
||||
return [{ wch: 50 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 15 }];
|
||||
}
|
||||
return [{ wch: 60 }, { wch: 40 }];
|
||||
};
|
||||
|
||||
export const DATA_SETS = [
|
||||
{
|
||||
zh: '生物学',
|
||||
en: 'Biology',
|
||||
file: 'mmlu-pro/biology.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '商业',
|
||||
en: 'Business',
|
||||
file: 'mmlu-pro/business.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '化学',
|
||||
en: 'Chemistry',
|
||||
file: 'mmlu-pro/chemistry.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '计算机科学',
|
||||
en: 'Computer Science',
|
||||
file: 'mmlu-pro/computer_science.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '经济学',
|
||||
en: 'Economics',
|
||||
file: 'mmlu-pro/economics.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '工程学',
|
||||
en: 'Engineering',
|
||||
file: 'mmlu-pro/engineering.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '健康科学',
|
||||
en: 'Health',
|
||||
file: 'mmlu-pro/health.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '历史',
|
||||
en: 'History',
|
||||
file: 'mmlu-pro/history.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '法律',
|
||||
en: 'Law',
|
||||
file: 'mmlu-pro/law.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '数学',
|
||||
en: 'Math',
|
||||
file: 'mmlu-pro/math.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '其他',
|
||||
en: 'Other',
|
||||
file: 'mmlu-pro/other.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '哲学',
|
||||
en: 'Philosophy',
|
||||
file: 'mmlu-pro/philosophy.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '物理',
|
||||
en: 'Physics',
|
||||
file: 'mmlu-pro/physics.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '心理学',
|
||||
en: 'Psychology',
|
||||
file: 'mmlu-pro/psychology.json',
|
||||
level: 'hard',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '抽象代数',
|
||||
en: 'Abstract Algebra',
|
||||
file: 'mmlu/abstract_algebra_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '解剖学',
|
||||
en: 'Anatomy',
|
||||
file: 'mmlu/anatomy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '天文学',
|
||||
en: 'Astronomy',
|
||||
file: 'mmlu/astronomy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '商业伦理',
|
||||
en: 'Business Ethics',
|
||||
file: 'mmlu/business_ethics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '临床知识',
|
||||
en: 'Clinical Knowledge',
|
||||
file: 'mmlu/clinical_knowledge_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学生物',
|
||||
en: 'College Biology',
|
||||
file: 'mmlu/college_biology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学化学',
|
||||
en: 'College Chemistry',
|
||||
file: 'mmlu/college_chemistry_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学计算机科学',
|
||||
en: 'College Computer Science',
|
||||
file: 'mmlu/college_computer_science_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学数学',
|
||||
en: 'College Mathematics',
|
||||
file: 'mmlu/college_mathematics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学医学',
|
||||
en: 'College Medicine',
|
||||
file: 'mmlu/college_medicine_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '大学物理',
|
||||
en: 'College Physics',
|
||||
file: 'mmlu/college_physics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '计算机安全',
|
||||
en: 'Computer Security',
|
||||
file: 'mmlu/computer_security_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '概念物理',
|
||||
en: 'Conceptual Physics',
|
||||
file: 'mmlu/conceptual_physics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '计量经济学',
|
||||
en: 'Econometrics',
|
||||
file: 'mmlu/econometrics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '电气工程',
|
||||
en: 'Electrical Engineering',
|
||||
file: 'mmlu/electrical_engineering_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '初等数学',
|
||||
en: 'Elementary Mathematics',
|
||||
file: 'mmlu/elementary_mathematics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '形式逻辑',
|
||||
en: 'Formal Logic',
|
||||
file: 'mmlu/formal_logic_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '全球事实',
|
||||
en: 'Global Facts',
|
||||
file: 'mmlu/global_facts_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中生物',
|
||||
en: 'High School Biology',
|
||||
file: 'mmlu/high_school_biology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中化学',
|
||||
en: 'High School Chemistry',
|
||||
file: 'mmlu/high_school_chemistry_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中计算机科学',
|
||||
en: 'High School Computer Science',
|
||||
file: 'mmlu/high_school_computer_science_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中欧洲历史',
|
||||
en: 'High School European History',
|
||||
file: 'mmlu/high_school_european_history_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中地理',
|
||||
en: 'High School Geography',
|
||||
file: 'mmlu/high_school_geography_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中政府与政治',
|
||||
en: 'High School Government And Politics',
|
||||
file: 'mmlu/high_school_government_and_politics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中宏观经济学',
|
||||
en: 'High School Macroeconomics',
|
||||
file: 'mmlu/high_school_macroeconomics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中数学',
|
||||
en: 'High School Mathematics',
|
||||
file: 'mmlu/high_school_mathematics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中微观经济学',
|
||||
en: 'High School Microeconomics',
|
||||
file: 'mmlu/high_school_microeconomics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中物理',
|
||||
en: 'High School Physics',
|
||||
file: 'mmlu/high_school_physics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中心理学',
|
||||
en: 'High School Psychology',
|
||||
file: 'mmlu/high_school_psychology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中统计学',
|
||||
en: 'High School Statistics',
|
||||
file: 'mmlu/high_school_statistics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中美国历史',
|
||||
en: 'High School Us History',
|
||||
file: 'mmlu/high_school_us_history_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '高中世界历史',
|
||||
en: 'High School World History',
|
||||
file: 'mmlu/high_school_world_history_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '人类衰老',
|
||||
en: 'Human Aging',
|
||||
file: 'mmlu/human_aging_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '人类性学',
|
||||
en: 'Human Sexuality',
|
||||
file: 'mmlu/human_sexuality_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '国际法',
|
||||
en: 'International Law',
|
||||
file: 'mmlu/international_law_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '法理学',
|
||||
en: 'Jurisprudence',
|
||||
file: 'mmlu/jurisprudence_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '逻辑谬误',
|
||||
en: 'Logical Fallacies',
|
||||
file: 'mmlu/logical_fallacies_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '机器学习',
|
||||
en: 'Machine Learning',
|
||||
file: 'mmlu/machine_learning_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '管理学',
|
||||
en: 'Management',
|
||||
file: 'mmlu/management_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '市场营销',
|
||||
en: 'Marketing',
|
||||
file: 'mmlu/marketing_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '医学遗传学',
|
||||
en: 'Medical Genetics',
|
||||
file: 'mmlu/medical_genetics_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '杂项/综合',
|
||||
en: 'Miscellaneous',
|
||||
file: 'mmlu/miscellaneous_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '道德争议',
|
||||
en: 'Moral Disputes',
|
||||
file: 'mmlu/moral_disputes_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '道德场景',
|
||||
en: 'Moral Scenarios',
|
||||
file: 'mmlu/moral_scenarios_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '营养学',
|
||||
en: 'Nutrition',
|
||||
file: 'mmlu/nutrition_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '哲学',
|
||||
en: 'Philosophy',
|
||||
file: 'mmlu/philosophy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '史前史',
|
||||
en: 'Prehistory',
|
||||
file: 'mmlu/prehistory_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业会计',
|
||||
en: 'Professional Accounting',
|
||||
file: 'mmlu/professional_accounting_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业法律',
|
||||
en: 'Professional Law',
|
||||
file: 'mmlu/professional_law_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业医学',
|
||||
en: 'Professional Medicine',
|
||||
file: 'mmlu/professional_medicine_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '专业心理学',
|
||||
en: 'Professional Psychology',
|
||||
file: 'mmlu/professional_psychology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '公共关系',
|
||||
en: 'Public Relations',
|
||||
file: 'mmlu/public_relations_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '安全研究',
|
||||
en: 'Security Studies',
|
||||
file: 'mmlu/security_studies_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '社会学',
|
||||
en: 'Sociology',
|
||||
file: 'mmlu/sociology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '美国外交政策',
|
||||
en: 'Us Foreign Policy',
|
||||
file: 'mmlu/us_foreign_policy_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '病毒学',
|
||||
en: 'Virology',
|
||||
file: 'mmlu/virology_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
},
|
||||
{
|
||||
zh: '世界宗教测试',
|
||||
en: 'World Religions',
|
||||
file: 'mmlu/world_religions_test.json',
|
||||
level: 'easy',
|
||||
type: 'single_choice'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Eval datasets list hook
|
||||
* @param {string} projectId
|
||||
*/
|
||||
export default function useEvalDatasets(projectId) {
|
||||
const [data, setData] = useState({ items: [], total: 0, stats: null, totalPages: 1 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const isInitialMount = useRef(true);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const [questionType, setQuestionType] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState('');
|
||||
const [chunkId, setChunkId] = useState('');
|
||||
const [tags, setTags] = useState([]);
|
||||
|
||||
const setQuestionTypeWithReset = useCallback(value => {
|
||||
setQuestionType(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const setKeywordWithReset = useCallback(value => {
|
||||
setKeyword(value);
|
||||
}, []);
|
||||
|
||||
const setChunkIdWithReset = useCallback(value => {
|
||||
setChunkId(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const setTagsWithReset = useCallback(value => {
|
||||
setTags(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const [viewMode, setViewMode] = useState('card');
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword);
|
||||
if (keyword !== debouncedKeyword) {
|
||||
setPage(1);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
const fetchDataRef = useRef(null);
|
||||
fetchDataRef.current = async (showLoading = true, options = {}) => {
|
||||
if (!projectId) return;
|
||||
|
||||
const includeStats = options.forceStats || showLoading;
|
||||
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setSearching(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
includeStats: includeStats ? 'true' : 'false'
|
||||
});
|
||||
|
||||
if (questionType) params.append('questionType', questionType);
|
||||
if (debouncedKeyword) params.append('keyword', debouncedKeyword);
|
||||
if (chunkId) params.append('chunkId', chunkId);
|
||||
if (tags.length > 0) {
|
||||
tags.forEach(tag => params.append('tags', tag));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets?${params}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch eval datasets');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(prev => ({
|
||||
...result,
|
||||
stats: result.stats ?? prev.stats
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return;
|
||||
setError(err.message);
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
} else {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback((showLoading = true, options = {}) => {
|
||||
return fetchDataRef.current?.(showLoading, options);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
fetchDataRef.current?.(true, { forceStats: true });
|
||||
} else {
|
||||
fetchDataRef.current?.(false, { forceStats: false });
|
||||
}
|
||||
}, [projectId, page, pageSize, questionType, debouncedKeyword, chunkId, tags]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deleteItems = useCallback(
|
||||
async ids => {
|
||||
if (!ids || ids.length === 0) return;
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete items');
|
||||
}
|
||||
|
||||
await fetchData(true, { forceStats: true });
|
||||
setSelectedIds([]);
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
[projectId, fetchData]
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setQuestionType('');
|
||||
setKeyword('');
|
||||
setChunkId('');
|
||||
setTags([]);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const toggleSelect = useCallback(id => {
|
||||
setSelectedIds(prev => (prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]));
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedIds.length === data.items.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(data.items.map(item => item.id));
|
||||
}
|
||||
}, [selectedIds, data.items]);
|
||||
|
||||
return {
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
stats: data.stats,
|
||||
totalPages: data.totalPages || 1,
|
||||
|
||||
loading,
|
||||
searching,
|
||||
error,
|
||||
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
setPageSize,
|
||||
|
||||
questionType,
|
||||
keyword,
|
||||
chunkId,
|
||||
tags,
|
||||
setQuestionType: setQuestionTypeWithReset,
|
||||
setKeyword: setKeywordWithReset,
|
||||
setChunkId: setChunkIdWithReset,
|
||||
setTags: setTagsWithReset,
|
||||
resetFilters,
|
||||
|
||||
viewMode,
|
||||
setViewMode,
|
||||
|
||||
selectedIds,
|
||||
toggleSelect,
|
||||
toggleSelectAll,
|
||||
setSelectedIds,
|
||||
|
||||
fetchData,
|
||||
deleteItems
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 评估数据集导出 Hook
|
||||
* 管理导出对话框状态、筛选条件和导出逻辑
|
||||
*/
|
||||
export default function useExportEvalDatasets(projectId, stats = {}) {
|
||||
// 对话框状态
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 导出配置
|
||||
const [format, setFormat] = useState('json');
|
||||
const [questionTypes, setQuestionTypes] = useState([]);
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// 预览数据
|
||||
const [previewTotal, setPreviewTotal] = useState(0);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
// 从 stats 中获取可用的标签列表
|
||||
const availableTags = stats?.byTag ? Object.keys(stats.byTag).sort() : [];
|
||||
|
||||
// 当筛选条件变化时,获取预览数量
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || !projectId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
setPreviewLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (questionTypes.length > 0) {
|
||||
questionTypes.forEach(t => params.append('questionTypes', t));
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
selectedTags.forEach(t => params.append('tags', t));
|
||||
}
|
||||
if (keyword.trim()) {
|
||||
params.append('keyword', keyword.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/export?${params.toString()}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setPreviewTotal(result?.data?.total ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('获取导出预览失败:', err);
|
||||
}
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPreview();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [dialogOpen, projectId, questionTypes, selectedTags, keyword]);
|
||||
|
||||
// 打开对话框
|
||||
const openDialog = useCallback(() => {
|
||||
setDialogOpen(true);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
// 关闭对话框
|
||||
const closeDialog = useCallback(() => {
|
||||
if (exporting) return;
|
||||
setDialogOpen(false);
|
||||
// 重置状态
|
||||
setFormat('json');
|
||||
setQuestionTypes([]);
|
||||
setSelectedTags([]);
|
||||
setKeyword('');
|
||||
setError('');
|
||||
}, [exporting]);
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = useCallback(() => {
|
||||
setQuestionTypes([]);
|
||||
setSelectedTags([]);
|
||||
setKeyword('');
|
||||
}, []);
|
||||
|
||||
// 执行导出
|
||||
const handleExport = useCallback(async () => {
|
||||
if (previewTotal === 0) {
|
||||
setError('没有符合条件的数据可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setExporting(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
questionTypes,
|
||||
tags: selectedTags,
|
||||
keyword: keyword.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json();
|
||||
throw new Error(result.error || '导出失败');
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `eval-datasets-${Date.now()}.${format}`;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
if (match) {
|
||||
filename = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
// 导出成功,关闭对话框
|
||||
closeDialog();
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('导出失败:', err);
|
||||
setError(err.message || '导出失败');
|
||||
return false;
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [projectId, format, questionTypes, selectedTags, keyword, previewTotal, closeDialog]);
|
||||
|
||||
return {
|
||||
// 对话框状态
|
||||
dialogOpen,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
|
||||
// 导出状态
|
||||
exporting,
|
||||
error,
|
||||
setError,
|
||||
|
||||
// 导出配置
|
||||
format,
|
||||
setFormat,
|
||||
questionTypes,
|
||||
setQuestionTypes,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
keyword,
|
||||
setKeyword,
|
||||
|
||||
// 预览数据
|
||||
previewTotal,
|
||||
previewLoading,
|
||||
availableTags,
|
||||
|
||||
// 操作
|
||||
resetFilters,
|
||||
handleExport
|
||||
};
|
||||
}
|
||||
322
easy-dataset-main/app/projects/[projectId]/eval-datasets/page.js
Normal file
322
easy-dataset-main/app/projects/[projectId]/eval-datasets/page.js
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Grid,
|
||||
Pagination,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Snackbar
|
||||
} from '@mui/material';
|
||||
import { Masonry } from '@mui/lab';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useEvalDatasets from './hooks/useEvalDatasets';
|
||||
import useExportEvalDatasets from './hooks/useExportEvalDatasets';
|
||||
import EvalToolbar from './components/EvalToolbar';
|
||||
import EvalDatasetCard from './components/EvalDatasetCard';
|
||||
import EvalDatasetList from './components/EvalDatasetList';
|
||||
import ImportDialog from './components/ImportDialog';
|
||||
import BuiltinDatasetDialog from './components/BuiltinDatasetDialog';
|
||||
import ExportEvalDialog from './components/ExportEvalDialog';
|
||||
|
||||
export default function EvalDatasetsPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
stats,
|
||||
totalPages,
|
||||
loading,
|
||||
searching,
|
||||
error,
|
||||
page,
|
||||
setPage,
|
||||
questionType,
|
||||
setQuestionType,
|
||||
tags,
|
||||
setTags,
|
||||
keyword,
|
||||
setKeyword,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
selectedIds,
|
||||
toggleSelect,
|
||||
toggleSelectAll,
|
||||
fetchData,
|
||||
deleteItems
|
||||
} = useEvalDatasets(projectId);
|
||||
|
||||
// 导出 Hook
|
||||
const {
|
||||
dialogOpen: exportDialogOpen,
|
||||
openDialog: openExportDialog,
|
||||
closeDialog: closeExportDialog,
|
||||
exporting,
|
||||
error: exportError,
|
||||
format: exportFormat,
|
||||
setFormat: setExportFormat,
|
||||
questionTypes: exportQuestionTypes,
|
||||
setQuestionTypes: setExportQuestionTypes,
|
||||
selectedTags: exportSelectedTags,
|
||||
setSelectedTags: setExportSelectedTags,
|
||||
keyword: exportKeyword,
|
||||
setKeyword: setExportKeyword,
|
||||
previewTotal,
|
||||
previewLoading,
|
||||
availableTags: exportAvailableTags,
|
||||
resetFilters: resetExportFilters,
|
||||
handleExport
|
||||
} = useExportEvalDatasets(projectId, stats);
|
||||
|
||||
// 删除确认对话框
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, ids: [] });
|
||||
|
||||
// 导入对话框
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [builtinImportOpen, setBuiltinImportOpen] = useState(false);
|
||||
|
||||
// Toast 提示
|
||||
const [toast, setToast] = useState({ open: false, message: '', severity: 'success' });
|
||||
|
||||
// 处理导入成功
|
||||
const handleImportSuccess = result => {
|
||||
setToast({
|
||||
open: true,
|
||||
message: t('evalDatasets.import.successMessage', { count: result.total }),
|
||||
severity: 'success'
|
||||
});
|
||||
fetchData(); // 刷新数据
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = async ids => {
|
||||
setDeleteDialog({ open: true, ids: Array.isArray(ids) ? ids : [ids] });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await deleteItems(deleteDialog.ids);
|
||||
setDeleteDialog({ open: false, ids: [] });
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = item => {
|
||||
router.push(`/projects/${projectId}/eval-datasets/${item.id}`);
|
||||
};
|
||||
|
||||
// 处理查看
|
||||
const handleView = item => {
|
||||
router.push(`/projects/${projectId}/eval-datasets/${item.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 工具栏(包含统计筛选) */}
|
||||
<EvalToolbar
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
selectedCount={selectedIds.length}
|
||||
onDeleteSelected={() => handleDelete(selectedIds)}
|
||||
stats={stats}
|
||||
questionType={questionType}
|
||||
onTypeChange={setQuestionType}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
onRefresh={fetchData}
|
||||
loading={loading}
|
||||
onImport={() => setImportDialogOpen(true)}
|
||||
onBuiltinImport={() => setBuiltinImportOpen(true)}
|
||||
onExport={openExportDialog}
|
||||
/>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 内容区域 */}
|
||||
{!loading && (
|
||||
<Box sx={{ position: 'relative', minHeight: searching ? 200 : 'auto' }}>
|
||||
{/* 搜索加载遮罩 */}
|
||||
{searching && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.7)',
|
||||
zIndex: 10,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{viewMode === 'card' ? (
|
||||
<Box>
|
||||
<Masonry
|
||||
columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
spacing={3}
|
||||
sx={{ opacity: searching ? 0.5 : 1, transition: 'opacity 0.2s', width: 'auto' }}
|
||||
>
|
||||
{items.map(item => (
|
||||
<EvalDatasetCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedIds.includes(item.id)}
|
||||
onSelect={toggleSelect}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
projectId={projectId}
|
||||
/>
|
||||
))}
|
||||
</Masonry>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ opacity: searching ? 0.5 : 1, transition: 'opacity 0.2s' }}>
|
||||
<EvalDatasetList
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={toggleSelect}
|
||||
onSelectAll={toggleSelectAll}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onView={handleView}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{items.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 8
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('eval.noData')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
{t('eval.noDataHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={(e, value) => setPage(value)}
|
||||
color="primary"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, ids: [] })}>
|
||||
<DialogTitle>{t('eval.deleteConfirmTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{t('eval.deleteConfirmMessage', { count: deleteDialog.ids.length })}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, ids: [] })}>{t('common.cancel')}</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 导入对话框 */}
|
||||
<ImportDialog
|
||||
open={importDialogOpen}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={handleImportSuccess}
|
||||
/>
|
||||
|
||||
{/* 内置数据集导入对话框 */}
|
||||
<BuiltinDatasetDialog
|
||||
open={builtinImportOpen}
|
||||
onClose={() => setBuiltinImportOpen(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={handleImportSuccess}
|
||||
/>
|
||||
|
||||
{/* 导出对话框 */}
|
||||
<ExportEvalDialog
|
||||
open={exportDialogOpen}
|
||||
onClose={closeExportDialog}
|
||||
exporting={exporting}
|
||||
error={exportError}
|
||||
format={exportFormat}
|
||||
setFormat={setExportFormat}
|
||||
questionTypes={exportQuestionTypes}
|
||||
setQuestionTypes={setExportQuestionTypes}
|
||||
selectedTags={exportSelectedTags}
|
||||
setSelectedTags={setExportSelectedTags}
|
||||
keyword={exportKeyword}
|
||||
setKeyword={setExportKeyword}
|
||||
previewTotal={previewTotal}
|
||||
previewLoading={previewLoading}
|
||||
availableTags={exportAvailableTags}
|
||||
resetFilters={resetExportFilters}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
|
||||
{/* Toast 提示 */}
|
||||
<Snackbar
|
||||
open={toast.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setToast({ ...toast, open: false })}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={toast.severity} onClose={() => setToast({ ...toast, open: false })}>
|
||||
{toast.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Paper, Typography, Chip, Grid, Divider } from '@mui/material';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import { detailStyles } from '../detailStyles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getModelIcon } from '@/lib/util/modelIcon';
|
||||
|
||||
export default function EvalHeader({ task, stats, filterCorrect, onFilterCorrectSelect }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
const { modelInfo, createAt, status, detail } = task;
|
||||
const score = detail?.finalScore || 0;
|
||||
const isPass = score >= 60;
|
||||
const totalTime = task.endTime ? Math.floor((new Date(task.endTime) - new Date(task.createAt)) / 1000) : 0;
|
||||
|
||||
const incorrectCount = (stats?.totalQuestions || 0) - (stats?.correctCount || 0);
|
||||
|
||||
// 获取教师模型信息
|
||||
const judgeModelId = detail?.judgeModelId;
|
||||
const judgeProviderId = detail?.judgeProviderId;
|
||||
const hasJudgeModel = judgeModelId && judgeProviderId;
|
||||
|
||||
return (
|
||||
<Paper sx={detailStyles.headerCard}>
|
||||
<Box sx={detailStyles.headerContent}>
|
||||
{/* 左侧:模型信息 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'transparent',
|
||||
border: '2px solid',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getModelIcon(modelInfo?.modelName || modelInfo?.modelId)}
|
||||
alt={modelInfo?.modelId || 'model'}
|
||||
style={{ width: 44, height: 44, objectFit: 'contain' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{modelInfo?.providerName || modelInfo?.providerId} / {modelInfo?.modelName || modelInfo?.modelId}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary', flexWrap: 'wrap' }}>
|
||||
{hasJudgeModel && (
|
||||
<Chip
|
||||
label={`${t('evalTasks.judgeModel')}: ${judgeProviderId} / ${judgeModelId}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', fontSize: '0.875rem' }}>
|
||||
<AccessTimeIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{new Date(createAt).toLocaleString()}
|
||||
{totalTime > 0 && ` ${t('evalTasks.durationFormat', { time: totalTime })}`}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 中间:统计概览 (增加点击筛选) */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mx: 4 }}>
|
||||
<Box
|
||||
onClick={() => onFilterCorrectSelect(null)}
|
||||
sx={{
|
||||
...detailStyles.statBox,
|
||||
cursor: 'pointer',
|
||||
bgcolor: filterCorrect === null ? 'rgba(25, 118, 210, 0.08)' : 'background.default',
|
||||
border: filterCorrect === null ? '1px solid' : '1px solid transparent',
|
||||
borderColor: 'primary.main',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" color="primary.main" fontWeight="bold">
|
||||
{stats?.totalQuestions || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('evalTasks.totalQuestionsLabel')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
onClick={() => onFilterCorrectSelect(true)}
|
||||
sx={{
|
||||
...detailStyles.statBox,
|
||||
cursor: 'pointer',
|
||||
bgcolor: filterCorrect === true ? 'rgba(46, 125, 50, 0.08)' : 'background.default',
|
||||
border: filterCorrect === true ? '1px solid' : '1px solid transparent',
|
||||
borderColor: 'success.main',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" color="success.main" fontWeight="bold">
|
||||
{stats?.correctCount || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('evalTasks.correctLabel')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
onClick={() => onFilterCorrectSelect(false)}
|
||||
sx={{
|
||||
...detailStyles.statBox,
|
||||
cursor: 'pointer',
|
||||
bgcolor: filterCorrect === false ? 'rgba(211, 47, 47, 0.08)' : 'background.default',
|
||||
border: filterCorrect === false ? '1px solid' : '1px solid transparent',
|
||||
borderColor: 'error.main',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" color="error.main" fontWeight="bold">
|
||||
{incorrectCount}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('evalTasks.incorrectLabel')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:分数印章 */}
|
||||
<Box sx={detailStyles.scoreStamp(score, isPass)}>
|
||||
<Typography sx={detailStyles.scoreValue}>{score.toFixed(1)}</Typography>
|
||||
<Typography sx={detailStyles.scoreLabel}>SCORE</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Grid, Typography, LinearProgress } from '@mui/material';
|
||||
import { detailStyles } from '../detailStyles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const QUESTION_TYPE_LABELS = {
|
||||
true_false: 'eval.questionTypes.true_false',
|
||||
single_choice: 'eval.questionTypes.single_choice',
|
||||
multiple_choice: 'eval.questionTypes.multiple_choice',
|
||||
short_answer: 'eval.questionTypes.short_answer',
|
||||
open_ended: 'eval.questionTypes.open_ended'
|
||||
};
|
||||
|
||||
export default function EvalStats({ stats, currentFilter, onFilterSelect }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!stats?.byType || Object.keys(stats.byType).length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(stats.byType).map(([type, typeStats]) => {
|
||||
const accuracy = typeStats.total > 0 ? (typeStats.correct / typeStats.total) * 100 : 0;
|
||||
|
||||
const isSelected = currentFilter === type;
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={2.4} key={type}>
|
||||
<Box
|
||||
onClick={() => onFilterSelect(isSelected ? null : type)}
|
||||
sx={{
|
||||
...detailStyles.typeStatsItem,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
bgcolor: isSelected ? 'primary.light' : '#fff',
|
||||
borderColor: isSelected ? 'primary.main' : '#eee',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
borderColor: 'primary.main'
|
||||
},
|
||||
'& *': {
|
||||
color: isSelected ? 'primary.contrastText' : undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
...detailStyles.typeStatsLabel,
|
||||
color: isSelected ? 'inherit' : 'text.secondary'
|
||||
}}
|
||||
>
|
||||
{t(QUESTION_TYPE_LABELS[type] || type)}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
...detailStyles.typeStatsScore,
|
||||
color: isSelected ? 'inherit' : 'text.primary'
|
||||
}}
|
||||
>
|
||||
{typeStats.correct} / {typeStats.total}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={accuracy}
|
||||
sx={{
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
bgcolor: isSelected ? 'rgba(255,255,255,0.3)' : undefined,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: isSelected ? 'white' : undefined
|
||||
}
|
||||
}}
|
||||
color={isSelected ? 'inherit' : accuracy >= 60 ? 'success' : 'error'}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
...detailStyles.typeStatsPercent,
|
||||
color: isSelected ? 'inherit' : 'text.secondary'
|
||||
}}
|
||||
>
|
||||
{accuracy.toFixed(0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Box, Typography, Chip, Paper, Button } from '@mui/material';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { detailStyles } from '../detailStyles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
// 答题状态常量
|
||||
const EVAL_STATUS = {
|
||||
SUCCESS: 0,
|
||||
FORMAT_ERROR: 1,
|
||||
API_ERROR: 2
|
||||
};
|
||||
|
||||
// 状态标签配置
|
||||
const STATUS_CONFIG = {
|
||||
[EVAL_STATUS.SUCCESS]: { label: 'evalTasks.statusSuccess', color: 'success' },
|
||||
[EVAL_STATUS.FORMAT_ERROR]: { label: 'evalTasks.statusFormatError', color: 'warning' },
|
||||
[EVAL_STATUS.API_ERROR]: { label: 'evalTasks.statusApiError', color: 'error' }
|
||||
};
|
||||
|
||||
export default function QuestionCard({ result, index, task }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
evalDataset,
|
||||
modelAnswer,
|
||||
isCorrect,
|
||||
score,
|
||||
judgeResponse,
|
||||
duration = 0,
|
||||
status = 0,
|
||||
errorMessage = ''
|
||||
} = result;
|
||||
const { question, questionType, options, correctAnswer } = evalDataset;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [shouldShowExpand, setShouldShowExpand] = useState(false);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const [isCorrectExpanded, setIsCorrectExpanded] = useState(false);
|
||||
const [shouldShowCorrectExpand, setShouldShowCorrectExpand] = useState(false);
|
||||
const correctContentRef = useRef(null);
|
||||
|
||||
// 检查内容是否超过高度限制
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const hasOverflow = contentRef.current.scrollHeight > 200;
|
||||
setShouldShowExpand(hasOverflow);
|
||||
}
|
||||
}, [modelAnswer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (correctContentRef.current) {
|
||||
const hasOverflow = correctContentRef.current.scrollHeight > 200;
|
||||
setShouldShowCorrectExpand(hasOverflow);
|
||||
}
|
||||
}, [correctAnswer]);
|
||||
|
||||
// 解析选项
|
||||
let parsedOptions = [];
|
||||
if (questionType === 'single_choice' || questionType === 'multiple_choice') {
|
||||
try {
|
||||
parsedOptions = JSON.parse(options);
|
||||
} catch (e) {
|
||||
parsedOptions = options ? [options] : [];
|
||||
}
|
||||
} else if (questionType === 'true_false') {
|
||||
parsedOptions = ['True', 'False'];
|
||||
}
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = ans => {
|
||||
if (!ans) return '-';
|
||||
return String(ans);
|
||||
};
|
||||
|
||||
// 判断选项状态
|
||||
const getOptionStatus = (optionText, idx) => {
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
const normModelAns = String(modelAnswer).trim();
|
||||
const normCorrectAns = String(correctAnswer).trim();
|
||||
|
||||
let isSelected = false;
|
||||
let isCorrectOption = false;
|
||||
|
||||
if (questionType === 'true_false') {
|
||||
// 判断题:A 对应 ✅/True,B 对应 ❌/False
|
||||
const isTrueOption = idx === 0;
|
||||
const isFalseOption = idx === 1;
|
||||
|
||||
isSelected =
|
||||
(isTrueOption && (normModelAns === '✅' || normModelAns.toUpperCase() === 'TRUE')) ||
|
||||
(isFalseOption && (normModelAns === '❌' || normModelAns.toUpperCase() === 'FALSE'));
|
||||
|
||||
isCorrectOption =
|
||||
(isTrueOption && (normCorrectAns === '✅' || normCorrectAns.toUpperCase() === 'TRUE')) ||
|
||||
(isFalseOption && (normCorrectAns === '❌' || normCorrectAns.toUpperCase() === 'FALSE'));
|
||||
} else {
|
||||
// 选择题逻辑
|
||||
const normModelAnsUpper = normModelAns.toUpperCase();
|
||||
const normCorrectAnsUpper = normCorrectAns.toUpperCase();
|
||||
const normOptionText = String(optionText).toUpperCase();
|
||||
|
||||
isSelected = normModelAnsUpper.includes(letter) || normModelAnsUpper.includes(normOptionText);
|
||||
isCorrectOption = normCorrectAnsUpper.includes(letter) || normCorrectAnsUpper.includes(normOptionText);
|
||||
}
|
||||
|
||||
return { isSelected, isCorrectOption };
|
||||
};
|
||||
|
||||
// 解析 AI 点评内容
|
||||
const getJudgeDisplayContent = content => {
|
||||
if (!content) return '';
|
||||
try {
|
||||
// 尝试从 markdown 代码块中提取 JSON
|
||||
const jsonMatch = content.match(/\{[\s\S]*?\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (parsed.reason) return parsed.reason;
|
||||
}
|
||||
// 尝试直接解析
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed.reason) return parsed.reason;
|
||||
} catch (e) {
|
||||
// 解析失败,返回原内容
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={detailStyles.questionCard(isCorrect)}>
|
||||
{/* 判卷标记 (红勾/红叉) - 绝对定位 */}
|
||||
<Box sx={detailStyles.markIcon(isCorrect)}>
|
||||
{isCorrect ? <CheckIcon fontSize="inherit" /> : <CloseIcon fontSize="inherit" />}
|
||||
</Box>
|
||||
|
||||
{/* 题号与类型标签 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Box
|
||||
sx={{
|
||||
...detailStyles.questionIndex,
|
||||
position: 'relative', // 改为相对定位
|
||||
top: 'auto',
|
||||
left: 'auto',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</Box>
|
||||
<Chip
|
||||
label={t(`eval.questionTypes.${questionType}`)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
|
||||
{/* 答题耗时 */}
|
||||
{duration > 0 && (
|
||||
<Chip
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 14 }} />}
|
||||
label={duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75, fontSize: '0.75rem' } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 答题状态 */}
|
||||
{status !== EVAL_STATUS.SUCCESS && (
|
||||
<Chip
|
||||
icon={<ErrorOutlineIcon sx={{ fontSize: 14 }} />}
|
||||
label={t(
|
||||
STATUS_CONFIG[status]?.label || 'evalTasks.statusUnknown',
|
||||
status === EVAL_STATUS.FORMAT_ERROR ? t('evalTasks.statusFormatError') : t('evalTasks.statusApiError')
|
||||
)}
|
||||
size="small"
|
||||
color={STATUS_CONFIG[status]?.color || 'default'}
|
||||
variant="outlined"
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75, fontSize: '0.75rem' } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 题目内容 */}
|
||||
<Box>
|
||||
<Typography sx={detailStyles.questionContent}>{question}</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 选项区域 (仅选择题/判断题) */}
|
||||
{parsedOptions.length > 0 && (
|
||||
<Box sx={detailStyles.optionsContainer}>
|
||||
{parsedOptions.map((opt, idx) => {
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
const { isSelected, isCorrectOption } = getOptionStatus(opt, idx);
|
||||
|
||||
return (
|
||||
<Box key={idx} sx={detailStyles.optionItem(isSelected, isCorrectOption)}>
|
||||
<Typography sx={{ fontWeight: 600, minWidth: 24 }}>{letter}.</Typography>
|
||||
<Typography>{opt}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 答案对比区域 */}
|
||||
<Box sx={detailStyles.answerSection}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
{t('evalTasks.modelAnswer')}
|
||||
</Typography>
|
||||
|
||||
<Box ref={contentRef} sx={detailStyles.markdownContainer(isExpanded)}>
|
||||
{questionType === 'open_ended' || questionType === 'short_answer' ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown>{modelAnswer || ''}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: isCorrect ? 'success.main' : 'error.main',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{formatAnswer(modelAnswer)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 展开/收起 遮罩和按钮 */}
|
||||
{shouldShowExpand && !isExpanded && (
|
||||
<Box sx={detailStyles.expandMask}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
startIcon={<ExpandMoreIcon />}
|
||||
sx={detailStyles.expandButton}
|
||||
>
|
||||
{t('common.expand', '展开全部')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isExpanded && shouldShowExpand && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
startIcon={<ExpandLessIcon />}
|
||||
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
|
||||
>
|
||||
{t('common.collapse', '收起内容')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
{t('evalTasks.correctAnswer')}
|
||||
</Typography>
|
||||
<Box ref={correctContentRef} sx={detailStyles.markdownContainer(isCorrectExpanded)}>
|
||||
{questionType === 'open_ended' || questionType === 'short_answer' ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown>{correctAnswer || ''}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ fontFamily: 'monospace', color: 'text.primary', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{formatAnswer(correctAnswer)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 展开/收起 遮罩和按钮 */}
|
||||
{shouldShowCorrectExpand && !isCorrectExpanded && (
|
||||
<Box sx={detailStyles.expandMask}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setIsCorrectExpanded(true)}
|
||||
startIcon={<ExpandMoreIcon />}
|
||||
sx={detailStyles.expandButton}
|
||||
>
|
||||
{t('common.expand', '展开全部')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isCorrectExpanded && shouldShowCorrectExpand && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setIsCorrectExpanded(false)}
|
||||
startIcon={<ExpandLessIcon />}
|
||||
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
|
||||
>
|
||||
{t('common.collapse', '收起内容')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 错误信息显示 */}
|
||||
{errorMessage && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
p: 1.5,
|
||||
bgcolor: 'error.lighter',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'error.light'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="error.main" sx={{ fontSize: '0.8rem' }}>
|
||||
{errorMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 教师点评 (气泡样式) */}
|
||||
{judgeResponse && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box sx={detailStyles.judgeComment}>
|
||||
<Typography sx={detailStyles.judgeLabel}>{t('evalTasks.judgeComment')}</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{getJudgeDisplayContent(judgeResponse)}
|
||||
</Typography>
|
||||
{/* 得分显示(如果是主观题) */}
|
||||
{(questionType === 'short_answer' || questionType === 'open_ended') && (
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 1,
|
||||
textAlign: 'right',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.2rem',
|
||||
borderTop: '1px dashed #d32f2f',
|
||||
pt: 0.5
|
||||
}}
|
||||
>
|
||||
{(score * 100).toFixed(0)} {t('evalTasks.scoreUnit')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
export const detailStyles = {
|
||||
// 页面背景
|
||||
pageContainer: {
|
||||
py: 4,
|
||||
minHeight: '100vh',
|
||||
bgcolor: '#f5f7fa'
|
||||
},
|
||||
|
||||
// 头部概览卡片
|
||||
headerCard: {
|
||||
mb: 3,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
|
||||
border: 'none'
|
||||
},
|
||||
|
||||
headerContent: {
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2
|
||||
},
|
||||
|
||||
// 分数印章效果
|
||||
scoreStamp: (score, isPass) => ({
|
||||
width: 110,
|
||||
height: 110,
|
||||
borderRadius: '50%',
|
||||
border: `4px double ${isPass ? '#2e7d32' : '#d32f2f'}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isPass ? '#2e7d32' : '#d32f2f',
|
||||
transform: 'rotate(-15deg)',
|
||||
maskImage:
|
||||
'url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZmlsdGVyIGlkPSJub2lzZSI+PGZlVHVyYnVsZW5jZSB0eXBlPSJmcmFjdGFsTm9pc2UiIGJhc2VGcmVxdWVuY3k9IjAuNSIgbnVtT2N0YXZlcz0iMyIgc3RpdGNoVGlsZXM9InN0aXRjaCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCNub2lzZSkiIG9wYWNpdHk9IjAuNSIvPjwvc3ZnPg==")', // 简单的噪点遮罩模拟印章纹理(可选)
|
||||
opacity: 0.9,
|
||||
boxShadow: 'inset 0 0 10px rgba(0,0,0,0.1)',
|
||||
flexShrink: 0
|
||||
}),
|
||||
|
||||
scoreValue: {
|
||||
fontSize: '2.2rem',
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.1,
|
||||
fontFamily: '"Comic Sans MS", "Chalkboard SE", sans-serif',
|
||||
mb: 0.2
|
||||
},
|
||||
|
||||
scoreLabel: {
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1
|
||||
},
|
||||
|
||||
// 统计卡片
|
||||
statBox: {
|
||||
textAlign: 'center',
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.default',
|
||||
minWidth: 100
|
||||
},
|
||||
|
||||
// 试卷主体
|
||||
paperContainer: {
|
||||
width: '100%',
|
||||
mx: 'auto',
|
||||
bgcolor: '#fff',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.08)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
border: '1px solid #e0e0e0'
|
||||
},
|
||||
|
||||
paperHeader: {
|
||||
p: 4,
|
||||
borderBottom: '2px solid #000',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
bgcolor: '#fff'
|
||||
},
|
||||
|
||||
paperTitle: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 700,
|
||||
mb: 1,
|
||||
fontFamily: '"Songti SC", "SimSun", serif' // 宋体增强试卷感
|
||||
},
|
||||
|
||||
paperSubTitle: {
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.9rem'
|
||||
},
|
||||
|
||||
// 题目部分
|
||||
questionSection: {
|
||||
p: 0
|
||||
},
|
||||
|
||||
questionCard: isCorrect => ({
|
||||
p: 3,
|
||||
height: '100%', // 确保在Grid中高度撑满
|
||||
borderBottom: '1px solid #f0f0f0', // 减淡边框颜色
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
bgcolor: '#fafafa'
|
||||
}
|
||||
}),
|
||||
|
||||
questionIndex: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
top: 24,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%', // 圆形题号
|
||||
border: '1px solid #ddd',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
bgcolor: '#fff',
|
||||
zIndex: 1,
|
||||
fontSize: '0.875rem'
|
||||
},
|
||||
|
||||
// 判卷标记(红勾/红叉)
|
||||
markIcon: isCorrect => ({
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: 20,
|
||||
fontSize: '3rem',
|
||||
color: isCorrect ? '#2e7d32' : '#d32f2f',
|
||||
opacity: 0.8,
|
||||
transform: 'rotate(10deg)',
|
||||
fontFamily: '"Comic Sans MS", "Chalkboard SE", sans-serif'
|
||||
}),
|
||||
|
||||
// 题目内容
|
||||
questionContent: {
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.6,
|
||||
mb: 2,
|
||||
color: '#333'
|
||||
},
|
||||
|
||||
// 选项区域
|
||||
optionsContainer: {
|
||||
pl: 2,
|
||||
mb: 2
|
||||
},
|
||||
|
||||
optionItem: (isSelected, isCorrectOption) => ({
|
||||
p: 1,
|
||||
mb: 0.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: isCorrectOption
|
||||
? 'rgba(46, 125, 50, 0.1)' // 正确选项显示绿色背景
|
||||
: isSelected
|
||||
? 'rgba(211, 47, 47, 0.1)'
|
||||
: 'transparent', // 错误选中显示红色背景
|
||||
color: isCorrectOption ? 'success.main' : isSelected ? 'error.main' : 'text.primary',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1
|
||||
}),
|
||||
|
||||
// 答案区域
|
||||
answerSection: {
|
||||
mt: 2,
|
||||
p: 2,
|
||||
bgcolor: '#f8f9fa',
|
||||
borderRadius: 2,
|
||||
borderLeft: '4px solid #ddd',
|
||||
position: 'relative'
|
||||
},
|
||||
|
||||
// Markdown 展示区域
|
||||
markdownContainer: isExpanded => ({
|
||||
maxHeight: isExpanded ? 'none' : '200px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
'& .markdown-body': {
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.6,
|
||||
bgcolor: 'transparent',
|
||||
color: 'inherit',
|
||||
padding: 0
|
||||
}
|
||||
}),
|
||||
|
||||
// 展开收起遮罩层(渐变效果)
|
||||
expandMask: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '60px',
|
||||
background: 'linear-gradient(transparent, #f8f9fa)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
pb: 1,
|
||||
zIndex: 1
|
||||
},
|
||||
|
||||
expandButton: {
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'none',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'rgba(255,255,255,0.8)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255,255,255,1)'
|
||||
},
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||
borderRadius: '16px',
|
||||
px: 2
|
||||
},
|
||||
|
||||
// 教师点评样式
|
||||
judgeComment: {
|
||||
mt: 2,
|
||||
position: 'relative',
|
||||
fontFamily: '"KaiTi", "KaiTi_GB2312", serif', // 楷体模拟手写点评
|
||||
color: '#d32f2f',
|
||||
padding: '10px 20px',
|
||||
border: '1px solid #d32f2f',
|
||||
borderRadius: '20px 20px 20px 4px', // 气泡形状
|
||||
maxWidth: 'fit-content',
|
||||
bgcolor: '#fff5f5'
|
||||
},
|
||||
|
||||
judgeLabel: {
|
||||
fontSize: '0.8rem',
|
||||
opacity: 0.7,
|
||||
fontStyle: 'italic',
|
||||
mb: 0.5
|
||||
},
|
||||
|
||||
// 按题型统计样式
|
||||
typeStatsItem: {
|
||||
textAlign: 'center',
|
||||
p: 2,
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 2,
|
||||
border: '1px solid #eee',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.03)',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
typeStatsLabel: {
|
||||
fontSize: '0.85rem',
|
||||
color: 'text.secondary',
|
||||
mb: 1
|
||||
},
|
||||
|
||||
typeStatsScore: {
|
||||
fontWeight: 700,
|
||||
fontSize: '1.25rem',
|
||||
color: 'text.primary'
|
||||
},
|
||||
|
||||
typeStatsPercent: {
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Grid,
|
||||
Pagination
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useEvalTaskDetail from '../hooks/useEvalTaskDetail';
|
||||
import { detailStyles } from './detailStyles';
|
||||
import EvalHeader from './components/EvalHeader';
|
||||
import EvalStats from './components/EvalStats';
|
||||
import QuestionCard from './components/QuestionCard';
|
||||
|
||||
export default function EvalTaskDetailPage() {
|
||||
const { projectId, taskId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
task,
|
||||
results,
|
||||
stats,
|
||||
total,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterCorrect,
|
||||
setFilterCorrect,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadData
|
||||
} = useEvalTaskDetail(projectId, taskId);
|
||||
|
||||
const handleFilterSelect = type => {
|
||||
setFilterType(type);
|
||||
setPage(1); // 切换筛选时重置到第一页
|
||||
};
|
||||
|
||||
const handleFilterCorrectSelect = isCorrect => {
|
||||
setFilterCorrect(isCorrect);
|
||||
setPage(1); // 切换筛选时重置到第一页
|
||||
};
|
||||
|
||||
const handlePageChange = (event, value) => {
|
||||
setPage(value);
|
||||
// 滚动到试卷顶部
|
||||
document.getElementById('paper-top')?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (loading && !task) {
|
||||
return (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{ ...detailStyles.pageContainer, display: 'flex', justifyContent: 'center', alignItems: 'center' }}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={detailStyles.pageContainer}>
|
||||
<Container maxWidth="xl">
|
||||
{/* 顶部导航栏 */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => router.back()}
|
||||
sx={{ color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'white' }}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 任务进度(仅进行中时显示) */}
|
||||
{task?.status === 0 && (
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight="bold">
|
||||
{t('evalTasks.statusProcessing')}...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="primary">
|
||||
{task.completedCount}/{task.totalCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={task.totalCount > 0 ? (task.completedCount / task.totalCount) * 100 : 0}
|
||||
sx={{ height: 10, borderRadius: 5 }}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* 核心内容区 */}
|
||||
{task && (
|
||||
<>
|
||||
{/* 头部概览 */}
|
||||
<EvalHeader
|
||||
task={task}
|
||||
stats={stats}
|
||||
filterCorrect={filterCorrect}
|
||||
onFilterCorrectSelect={handleFilterCorrectSelect}
|
||||
/>
|
||||
|
||||
{/* 统计图表 & 筛选 */}
|
||||
<EvalStats stats={stats} currentFilter={filterType} onFilterSelect={handleFilterSelect} />
|
||||
|
||||
{/* 试卷主体 */}
|
||||
<Box sx={detailStyles.paperContainer} id="paper-top">
|
||||
{/* 试卷抬头 */}
|
||||
<Box sx={detailStyles.paperHeader}>
|
||||
<Typography sx={detailStyles.paperTitle}>{t('evalTasks.reportTitle', '模型能力评估报告')}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: 3,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
{t('evalTasks.taskIdLabel', '任务 ID')}: {taskId}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{t('evalTasks.pageInfo', '第 {{page}} / {{totalPages}} 页', {
|
||||
page,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 题目列表 (双列布局) */}
|
||||
<Box sx={{ p: 3, bgcolor: '#fff' }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{results?.map((result, index) => (
|
||||
<Grid item xs={12} md={6} key={result.id}>
|
||||
<QuestionCard result={result} index={(page - 1) * pageSize + index} task={task} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{!loading && results?.length === 0 && (
|
||||
<Box sx={{ p: 8, textAlign: 'center', color: 'text.disabled' }}>
|
||||
<Typography>{t('evalTasks.noMatchingResults', '暂无符合条件的评估结果')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center', borderTop: '1px solid #eee' }}>
|
||||
<Pagination
|
||||
count={Math.ceil(total / pageSize)}
|
||||
page={page}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
size="large"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 试卷底部 */}
|
||||
<Box sx={{ p: 4, textAlign: 'center', color: 'text.disabled', borderTop: '2px solid #000' }}>
|
||||
<Typography variant="caption">
|
||||
{t('evalTasks.reportFooter', 'Easy Dataset Evaluation System · Generated by AI')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
FormHelperText
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelSelector from './ModelSelector';
|
||||
import QuestionFilter from './QuestionFilter';
|
||||
import ScoreAnchorsForm from './ScoreAnchorsForm';
|
||||
import { useEvalTaskForm } from '../hooks/useEvalTaskForm';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function CreateEvalTaskDialog({ open, onClose, projectId, onSuccess }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
models,
|
||||
selectedModels,
|
||||
setSelectedModels,
|
||||
judgeModel,
|
||||
setJudgeModel,
|
||||
evalDatasets,
|
||||
availableTags,
|
||||
questionTypes,
|
||||
setQuestionTypes,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
questionCount,
|
||||
setQuestionCount,
|
||||
filteredTotal,
|
||||
sampledIds,
|
||||
hasSubjectiveQuestions,
|
||||
hasShortAnswer,
|
||||
hasOpenEnded,
|
||||
shortAnswerScoreAnchors,
|
||||
setShortAnswerScoreAnchors,
|
||||
openEndedScoreAnchors,
|
||||
setOpenEndedScoreAnchors,
|
||||
initScoreAnchors,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
setSampledIds,
|
||||
resetFilters,
|
||||
resetForm
|
||||
} = useEvalTaskForm(projectId, open);
|
||||
|
||||
// 当有主观题时,初始化评分规则
|
||||
useEffect(() => {
|
||||
if (hasSubjectiveQuestions && open) {
|
||||
initScoreAnchors(i18n.language === 'zh-CN' ? 'zh-CN' : 'en');
|
||||
}
|
||||
}, [hasSubjectiveQuestions, open, i18n.language]);
|
||||
|
||||
// 统计各题型数量
|
||||
const typeStats = {};
|
||||
evalDatasets.forEach(d => {
|
||||
typeStats[d.questionType] = (typeStats[d.questionType] || 0) + 1;
|
||||
});
|
||||
|
||||
const getModelKey = model => `${model.providerId}::${model.modelId}`;
|
||||
|
||||
const handleModelSelectionChange = newSelection => {
|
||||
setSelectedModels(newSelection);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 先清除之前的错误
|
||||
setError('');
|
||||
|
||||
// 验证
|
||||
if (selectedModels.length === 0) {
|
||||
setError(t('evalTasks.errorNoModels'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredTotal === 0) {
|
||||
setError(t('evalTasks.errorNoQuestions'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSubjectiveQuestions && !judgeModel) {
|
||||
setError(t('evalTasks.errorNoJudgeModel'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证教师模型不在测试模型中
|
||||
if (judgeModel && selectedModels.includes(judgeModel)) {
|
||||
setError(t('evalTasks.errorJudgeSameAsTest'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
// 解析选中的模型
|
||||
const models = selectedModels.map(m => {
|
||||
const [providerId, modelId] = m.split('::');
|
||||
return { modelId, providerId }; // 注意顺序:modelId 在前
|
||||
});
|
||||
|
||||
// 解析教师模型
|
||||
let judgeModelId = null;
|
||||
let judgeProviderId = null;
|
||||
if (judgeModel) {
|
||||
const [pId, mId] = judgeModel.split('::');
|
||||
judgeProviderId = pId;
|
||||
judgeModelId = mId;
|
||||
}
|
||||
|
||||
// 调用后端采样接口获取题目 ID
|
||||
const sampleBody = {
|
||||
questionTypes: questionTypes,
|
||||
tags: selectedTags,
|
||||
keyword: searchKeyword.trim() || '',
|
||||
limit: questionCount > 0 ? questionCount : undefined
|
||||
};
|
||||
|
||||
const sampleResponse = await fetch(`/api/projects/${projectId}/eval-datasets/sample`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sampleBody)
|
||||
});
|
||||
|
||||
const sampleResult = await sampleResponse.json();
|
||||
if (!sampleResponse.ok || sampleResult.code !== 0) {
|
||||
setError(sampleResult.error || t('evalTasks.errorCreateFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = sampleResult?.data?.ids || [];
|
||||
if (ids.length === 0) {
|
||||
setError(t('evalTasks.errorNoQuestions'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSampledIds(ids);
|
||||
|
||||
// 构建自定义评分规则对象
|
||||
const customScoreAnchors = {};
|
||||
if (hasShortAnswer && shortAnswerScoreAnchors.length > 0) {
|
||||
customScoreAnchors.short_answer = shortAnswerScoreAnchors;
|
||||
}
|
||||
if (hasOpenEnded && openEndedScoreAnchors.length > 0) {
|
||||
customScoreAnchors.open_ended = openEndedScoreAnchors;
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
models, // 后端期望的字段名
|
||||
judgeModelId, // 分开传递
|
||||
judgeProviderId, // 分开传递
|
||||
evalDatasetIds: ids,
|
||||
language: i18n.language === 'zh-CN' ? 'zh-CN' : 'en',
|
||||
customScoreAnchors: Object.keys(customScoreAnchors).length > 0 ? customScoreAnchors : undefined
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
onSuccess && onSuccess(result.data);
|
||||
handleClose();
|
||||
} else {
|
||||
setError(result.error || t('evalTasks.errorCreateFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建评估任务失败:', err);
|
||||
setError(t('evalTasks.errorCreateFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleJudgeModelChange = event => {
|
||||
setJudgeModel(event.target.value);
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('evalTasks.createTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 选择测试模型 */}
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModels={selectedModels}
|
||||
onSelectionChange={handleModelSelectionChange}
|
||||
error={selectedModels.length === 0 && error}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 题目筛选 */}
|
||||
<QuestionFilter
|
||||
questionTypes={questionTypes}
|
||||
selectedTags={selectedTags}
|
||||
searchKeyword={searchKeyword}
|
||||
questionCount={questionCount}
|
||||
availableTags={availableTags}
|
||||
typeStats={typeStats}
|
||||
filteredCount={filteredTotal}
|
||||
onQuestionTypesChange={setQuestionTypes}
|
||||
onTagsChange={setSelectedTags}
|
||||
onSearchChange={setSearchKeyword}
|
||||
onQuestionCountChange={setQuestionCount}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
{/* 最终题目统计 */}
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalTasks.finalSelection')}
|
||||
<strong>{sampledIds.length || (questionCount > 0 ? questionCount : filteredTotal)}</strong>{' '}
|
||||
{t('evalTasks.questionsSuffix')}
|
||||
</Typography>
|
||||
{hasSubjectiveQuestions && (
|
||||
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
|
||||
{t('evalTasks.hasSubjectiveHint')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 选择教师模型(仅当有主观题时显示) */}
|
||||
{hasSubjectiveQuestions && (
|
||||
<>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>{t('evalTasks.selectJudgeModel')} *</InputLabel>
|
||||
<Select
|
||||
value={judgeModel}
|
||||
onChange={handleJudgeModelChange}
|
||||
label={`${t('evalTasks.selectJudgeModel')} *`}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>{t('evalTasks.selectJudgeModelPlaceholder')}</em>
|
||||
</MenuItem>
|
||||
{models
|
||||
.filter(m => {
|
||||
const key = `${m.providerId}::${m.modelId}`;
|
||||
return !selectedModels.includes(key);
|
||||
})
|
||||
.map(model => {
|
||||
const key = `${model.providerId}::${model.modelId}`;
|
||||
return (
|
||||
<MenuItem key={key} value={key}>
|
||||
{model.providerName} / {model.modelName}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<FormHelperText>{t('evalTasks.selectJudgeModelHint')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* 简答题评分规则 */}
|
||||
{hasShortAnswer && (
|
||||
<ScoreAnchorsForm
|
||||
questionType="short_answer"
|
||||
scoreAnchors={shortAnswerScoreAnchors}
|
||||
onChange={setShortAnswerScoreAnchors}
|
||||
language={i18n.language}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 开放题评分规则 */}
|
||||
{hasOpenEnded && (
|
||||
<ScoreAnchorsForm
|
||||
questionType="open_ended"
|
||||
scoreAnchors={openEndedScoreAnchors}
|
||||
onChange={setOpenEndedScoreAnchors}
|
||||
language={i18n.language}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={submitting || loading}
|
||||
startIcon={submitting && <CircularProgress size={16} />}
|
||||
>
|
||||
{submitting ? t('common.creating') : t('evalTasks.startEval')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
Avatar,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import PauseCircleIcon from '@mui/icons-material/PauseCircle';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import QuizIcon from '@mui/icons-material/Quiz';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getModelIcon } from '@/lib/util/modelIcon';
|
||||
import styles from '../styles';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
0: { label: 'evalTasks.statusProcessing', color: 'info', icon: HourglassEmptyIcon },
|
||||
1: { label: 'evalTasks.statusCompleted', color: 'success', icon: CheckCircleIcon },
|
||||
2: { label: 'evalTasks.statusFailed', color: 'error', icon: ErrorIcon },
|
||||
3: { label: 'evalTasks.statusInterrupted', color: 'warning', icon: PauseCircleIcon }
|
||||
};
|
||||
|
||||
export default function EvalTaskCard({ task, onView, onDelete, onInterrupt }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const { modelInfo, detail, status, completedCount, totalCount, createAt } = task;
|
||||
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG[0];
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
|
||||
const finalScore = detail?.finalScore;
|
||||
|
||||
const handleMenuClick = e => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
|
||||
const handleAction = action => () => {
|
||||
handleMenuClose();
|
||||
action?.(task);
|
||||
};
|
||||
|
||||
const getScoreColor = score => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'info';
|
||||
if (score >= 40) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={styles.taskCard(theme)} onClick={handleAction(onView)}>
|
||||
<CardContent sx={styles.taskCardContent}>
|
||||
{/* 头部 */}
|
||||
<Box sx={styles.taskCardHeader}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1, overflow: 'hidden' }}>
|
||||
<Avatar sx={{ bgcolor: 'transparent', width: 40, height: 40, border: '1px solid', borderColor: 'divider' }}>
|
||||
<img
|
||||
src={getModelIcon(modelInfo?.modelName || modelInfo?.modelId)}
|
||||
alt={modelInfo?.modelId || 'model'}
|
||||
style={{ width: 28, height: 28, objectFit: 'contain' }}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box sx={styles.taskCardModel}>
|
||||
<Typography sx={styles.taskCardModelName} noWrap>
|
||||
{modelInfo?.modelName || modelInfo?.modelId}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{modelInfo?.providerName || modelInfo?.providerId}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={handleMenuClick}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* 状态和得分 */}
|
||||
<Box sx={styles.taskCardStatus}>
|
||||
<Chip
|
||||
icon={<StatusIcon sx={{ fontSize: 14 }} />}
|
||||
label={t(statusConfig.label)}
|
||||
color={statusConfig.color}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 1, fontSize: '0.7rem' } }}
|
||||
/>
|
||||
{finalScore !== undefined && status === 1 && (
|
||||
<Chip
|
||||
label={`${finalScore.toFixed(1)}%`}
|
||||
color={getScoreColor(finalScore)}
|
||||
size="small"
|
||||
sx={{ height: 24, fontWeight: 600, '& .MuiChip-label': { px: 1 } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 进度条 */}
|
||||
{status === 0 && (
|
||||
<Box sx={styles.taskCardProgress}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('evalTasks.progress')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="primary" fontWeight={600}>
|
||||
{completedCount}/{totalCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={progress} sx={styles.progressBar} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 统计信息 */}
|
||||
<Box sx={styles.taskCardStats}>
|
||||
<Chip
|
||||
icon={<QuizIcon sx={{ fontSize: 14 }} />}
|
||||
label={`${totalCount} ${t('evalTasks.questions')}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 22, '& .MuiChip-label': { px: 0.75, fontSize: '0.7rem' } }}
|
||||
/>
|
||||
{detail?.hasSubjectiveQuestions && (
|
||||
<Chip
|
||||
label={t('evalTasks.hasSubjective')}
|
||||
size="small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
sx={{ height: 22, '& .MuiChip-label': { px: 0.75, fontSize: '0.7rem' } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 时间 */}
|
||||
<Typography sx={{ ...styles.taskCardTime, mt: 1.5 }} color="text.disabled">
|
||||
{new Date(createAt).toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
{/* 菜单 */}
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleMenuClose} onClick={e => e.stopPropagation()}>
|
||||
<MenuItem onClick={handleAction(onView)}>
|
||||
<ListItemIcon>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t('datasets.viewDetails')}
|
||||
</MenuItem>
|
||||
{status === 0 && (
|
||||
<MenuItem onClick={handleAction(onInterrupt)}>
|
||||
<ListItemIcon>
|
||||
<StopIcon fontSize="small" color="warning" />
|
||||
</ListItemIcon>
|
||||
{t('evalTasks.interrupt')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleAction(onDelete)} sx={{ color: 'error.main' }}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
{t('common.delete')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Checkbox,
|
||||
FormHelperText,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
ListItemText,
|
||||
OutlinedInput,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ModelSelector({ models, selectedModels, onSelectionChange, error }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getModelKey = model => `${model.providerId}::${model.modelId}`;
|
||||
|
||||
const handleChange = event => {
|
||||
const {
|
||||
target: { value }
|
||||
} = event;
|
||||
// On autofill we get a stringified value.
|
||||
onSelectionChange(typeof value === 'string' ? value.split(',') : value);
|
||||
};
|
||||
|
||||
const getModelLabel = modelKey => {
|
||||
const model = models.find(m => getModelKey(m) === modelKey);
|
||||
if (!model) return modelKey;
|
||||
return `${model.providerName || model.providerId} / ${model.modelName || model.modelId}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControl fullWidth error={!!error} size="small">
|
||||
<InputLabel id="model-selector-label">{t('evalTasks.selectModels')} *</InputLabel>
|
||||
<Select
|
||||
labelId="model-selector-label"
|
||||
multiple
|
||||
value={selectedModels}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput label={`${t('evalTasks.selectModels')} *`} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={getModelLabel(value)} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 300,
|
||||
width: 250
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{models.length === 0 ? (
|
||||
<MenuItem disabled value="">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalTasks.noModelsAvailable')}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
) : (
|
||||
models.map(model => {
|
||||
const modelKey = getModelKey(model);
|
||||
return (
|
||||
<MenuItem key={modelKey} value={modelKey}>
|
||||
<Checkbox checked={selectedModels.includes(modelKey)} />
|
||||
<ListItemText
|
||||
primary={`${model.providerName || model.providerId} / ${model.modelName || model.modelId}`}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Select>
|
||||
<FormHelperText>{error || t('evalTasks.selectModelsHint')}</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Slider,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: 'true_false', labelKey: 'eval.questionTypes.true_false' },
|
||||
{ value: 'single_choice', labelKey: 'eval.questionTypes.single_choice' },
|
||||
{ value: 'multiple_choice', labelKey: 'eval.questionTypes.multiple_choice' },
|
||||
{ value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' },
|
||||
{ value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' }
|
||||
];
|
||||
|
||||
export default function QuestionFilter({
|
||||
questionTypes,
|
||||
selectedTags,
|
||||
searchKeyword,
|
||||
questionCount,
|
||||
availableTags,
|
||||
filteredCount,
|
||||
onQuestionTypesChange,
|
||||
onTagsChange,
|
||||
onSearchChange,
|
||||
onQuestionCountChange,
|
||||
onReset
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasFilters = questionTypes.length > 0 || selectedTags.length > 0 || searchKeyword || questionCount > 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<FilterAltIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
{t('evalTasks.filterTitle')}
|
||||
</Typography>
|
||||
{hasFilters && (
|
||||
<Button size="small" startIcon={<ClearIcon />} onClick={onReset}>
|
||||
{t('evalTasks.clearFilter')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* 关键字搜索 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('evalTasks.searchKeyword')}
|
||||
placeholder={t('evalTasks.searchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* 题型和标签筛选 - 并排显示 */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 题型筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('evalTasks.filterByTypeLabel')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={questionTypes}
|
||||
onChange={e => onQuestionTypesChange(e.target.value)}
|
||||
input={<OutlinedInput label={t('evalTasks.filterByTypeLabel')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={t(`eval.questionTypes.${value}`)} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
<Checkbox checked={questionTypes.includes(type.value)} />
|
||||
<ListItemText primary={`${t(type.labelKey)} `} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
{availableTags.length > 0 && (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('evalTasks.filterByTagLabel')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={e => onTagsChange(e.target.value)}
|
||||
input={<OutlinedInput label={t('evalTasks.filterByTagLabel')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{availableTags.map(tag => (
|
||||
<MenuItem key={tag} value={tag}>
|
||||
<Checkbox checked={selectedTags.includes(tag)} />
|
||||
<ListItemText primary={tag} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 题目数量选择 - 紧凑布局 */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
||||
{t('evalTasks.questionCountLabel')}
|
||||
{questionCount === 0 ? t('common.all') : questionCount} / {filteredCount}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={questionCount}
|
||||
onChange={e => onQuestionCountChange(parseInt(e.target.value) || 0)}
|
||||
inputProps={{ min: 0, max: filteredCount }}
|
||||
sx={{ width: 100 }}
|
||||
/>
|
||||
</Box>
|
||||
<Slider
|
||||
value={questionCount}
|
||||
onChange={(e, value) => onQuestionCountChange(value)}
|
||||
min={0}
|
||||
max={filteredCount}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{questionCount === 0
|
||||
? t('evalTasks.useAllQuestions')
|
||||
: t('evalTasks.randomSampleHint', { filteredCount, questionCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getDefaultScoreAnchors } from '@/lib/llm/prompts/llmJudge';
|
||||
|
||||
/**
|
||||
* 评分规则表单组件
|
||||
* 用于自定义简答题和开放题的评分规则
|
||||
*/
|
||||
export default function ScoreAnchorsForm({
|
||||
questionType, // 'short_answer' 或 'open_ended'
|
||||
scoreAnchors,
|
||||
onChange,
|
||||
language = 'zh-CN'
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// 获取当前语言
|
||||
const currentLanguage = i18n.language === 'zh-CN' ? 'zh-CN' : 'en';
|
||||
|
||||
// 初始化评分规则(如果为空)
|
||||
useEffect(() => {
|
||||
if (!scoreAnchors || scoreAnchors.length === 0) {
|
||||
onChange(getDefaultScoreAnchors(questionType, currentLanguage));
|
||||
}
|
||||
}, [questionType, currentLanguage]);
|
||||
|
||||
// 处理单个规则的描述更改
|
||||
const handleDescriptionChange = (index, newDescription) => {
|
||||
const newAnchors = [...scoreAnchors];
|
||||
newAnchors[index] = { ...newAnchors[index], description: newDescription };
|
||||
onChange(newAnchors);
|
||||
};
|
||||
|
||||
// 恢复默认值
|
||||
const handleRestore = () => {
|
||||
onChange(getDefaultScoreAnchors(questionType, currentLanguage));
|
||||
};
|
||||
|
||||
// 获取题型显示名称
|
||||
const getQuestionTypeName = () => {
|
||||
if (questionType === 'short_answer') {
|
||||
return t('evalTasks.shortAnswer', '简答题');
|
||||
}
|
||||
return t('evalTasks.openEnded', '开放题');
|
||||
};
|
||||
|
||||
// 获取分数区间的颜色
|
||||
const getScoreColor = range => {
|
||||
if (range === '1.0') return 'success';
|
||||
if (range.includes('0.8') || range.includes('0.9')) return 'info';
|
||||
if (range.includes('0.6') || range.includes('0.7')) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
if (!scoreAnchors || scoreAnchors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={expanded}
|
||||
onChange={(e, isExpanded) => setExpanded(isExpanded)}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'&:before': { display: 'none' },
|
||||
boxShadow: 1
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
'&:hover': { bgcolor: 'action.selected' }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{t('evalTasks.scoreAnchorsTitle', '{{type}}评分规则', { type: getQuestionTypeName() })}
|
||||
</Typography>
|
||||
<Chip label={t('evalTasks.customizable', '可自定义')} size="small" color="primary" variant="outlined" />
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalTasks.scoreAnchorsHint', '自定义评分标准,用于指导LLM评估模型的回答质量')}
|
||||
</Typography>
|
||||
<Tooltip title={t('evalTasks.restoreDefault', '恢复默认')}>
|
||||
<IconButton size="small" onClick={handleRestore} color="primary">
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{scoreAnchors.map((anchor, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Chip
|
||||
label={anchor.range}
|
||||
size="small"
|
||||
color={getScoreColor(anchor.range)}
|
||||
sx={{ minWidth: 70, fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('evalTasks.scoreRange', '分数区间')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
multiline
|
||||
rows={2}
|
||||
value={anchor.description}
|
||||
onChange={e => handleDescriptionChange(index, e.target.value)}
|
||||
placeholder={t('evalTasks.scoreDescriptionPlaceholder', '请输入该分数区间的评分标准描述...')}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 评估任务详情 Hook
|
||||
*/
|
||||
export default function useEvalTaskDetail(projectId, taskId) {
|
||||
const [task, setTask] = useState(null);
|
||||
const [results, setResults] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 分页和筛选状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [filterType, setFilterType] = useState(null);
|
||||
const [filterCorrect, setFilterCorrect] = useState(null); // null: all, true: correct, false: incorrect
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 加载任务详情
|
||||
const loadData = useCallback(async () => {
|
||||
if (!projectId || !taskId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (filterType) {
|
||||
params.append('type', filterType);
|
||||
}
|
||||
|
||||
if (filterCorrect !== null) {
|
||||
params.append('isCorrect', filterCorrect.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setTask(result.data.task);
|
||||
setResults(result.data.results || []);
|
||||
setTotal(result.data.total || 0);
|
||||
setStats(result.data.stats);
|
||||
} else {
|
||||
setError(result.error || '加载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载任务详情失败:', err);
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, taskId, page, pageSize, filterType, filterCorrect]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 自动刷新进行中的任务 (仅在第一页且无筛选时刷新,避免干扰用户查看历史记录)
|
||||
useEffect(() => {
|
||||
if (task?.status !== 0 || page !== 1 || filterType || filterCorrect !== null) return;
|
||||
|
||||
const interval = setInterval(loadData, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [task?.status, page, filterType, filterCorrect, loadData]);
|
||||
|
||||
return {
|
||||
task,
|
||||
results,
|
||||
stats,
|
||||
total,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterCorrect,
|
||||
setFilterCorrect,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadData
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getDefaultScoreAnchors } from '@/lib/llm/prompts/llmJudge';
|
||||
|
||||
export function useEvalTaskForm(projectId, open) {
|
||||
const [models, setModels] = useState([]);
|
||||
const [selectedModels, setSelectedModels] = useState([]);
|
||||
const [judgeModel, setJudgeModel] = useState('');
|
||||
const [evalDatasets, setEvalDatasets] = useState([]);
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
|
||||
// 筛选条件
|
||||
const [questionTypes, setQuestionTypes] = useState([]);
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [questionCount, setQuestionCount] = useState(0);
|
||||
|
||||
// 后端统计 & 采样结果
|
||||
const [filteredTotal, setFilteredTotal] = useState(0);
|
||||
const [sampledIds, setSampledIds] = useState([]);
|
||||
const [hasSubjectiveQuestions, setHasSubjectiveQuestions] = useState(false);
|
||||
// 主观题类型统计(用于确定显示哪个评分规则表单)
|
||||
const [hasShortAnswer, setHasShortAnswer] = useState(false);
|
||||
const [hasOpenEnded, setHasOpenEnded] = useState(false);
|
||||
|
||||
// 自定义评分规则
|
||||
const [shortAnswerScoreAnchors, setShortAnswerScoreAnchors] = useState([]);
|
||||
const [openEndedScoreAnchors, setOpenEndedScoreAnchors] = useState([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (open && projectId) {
|
||||
loadModels();
|
||||
loadEvalDatasets();
|
||||
}
|
||||
}, [open, projectId]);
|
||||
|
||||
// 当筛选条件变化时,调用后端统计数量
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchCount = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (questionTypes.length > 0) {
|
||||
questionTypes.forEach(t => params.append('questionTypes', t));
|
||||
}
|
||||
if (searchKeyword.trim()) {
|
||||
params.append('keyword', searchKeyword.trim());
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
selectedTags.forEach(tag => params.append('tags', tag));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/count?${params.toString()}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const total = result?.data?.total ?? 0;
|
||||
const hasSubjective = result?.data?.hasSubjective ?? false;
|
||||
const hasShort = result?.data?.hasShortAnswer ?? false;
|
||||
const hasOpen = result?.data?.hasOpenEnded ?? false;
|
||||
setFilteredTotal(total);
|
||||
setHasSubjectiveQuestions(hasSubjective);
|
||||
setHasShortAnswer(hasShort);
|
||||
setHasOpenEnded(hasOpen);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('加载评估题目数量失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchCount();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, projectId, questionTypes, selectedTags, searchKeyword]);
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/model-config`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const modelList = result?.data || [];
|
||||
const availableModels = modelList.filter(m => m.apiKey && m.apiKey.trim() !== '' && m.status === 1);
|
||||
setModels(availableModels);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载模型列表失败:', err);
|
||||
setModels([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEvalDatasets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 这里只需要拿到全部可用标签和题型分布,可以复用已有列表接口或标签接口
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets?includeStats=true&page=1&pageSize=20`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const stats = data.stats || {};
|
||||
const byTag = stats.byTag || {};
|
||||
const tags = Object.keys(byTag);
|
||||
setAvailableTags(tags.sort());
|
||||
|
||||
// 用部分数据来判断是否存在主观题(类型统计更准确)
|
||||
const byType = stats.byType || {};
|
||||
const mockDatasets = Object.entries(byType).map(([type]) => ({ questionType: type }));
|
||||
setEvalDatasets(mockDatasets);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载评估题目失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuestionTypes([]);
|
||||
setSelectedTags([]);
|
||||
setSearchKeyword('');
|
||||
setQuestionCount(0);
|
||||
setFilteredTotal(0);
|
||||
setSampledIds([]);
|
||||
setHasShortAnswer(false);
|
||||
setHasOpenEnded(false);
|
||||
};
|
||||
|
||||
// 初始化评分规则(根据语言环境)
|
||||
const initScoreAnchors = (language = 'zh-CN') => {
|
||||
setShortAnswerScoreAnchors(getDefaultScoreAnchors('short_answer', language));
|
||||
setOpenEndedScoreAnchors(getDefaultScoreAnchors('open_ended', language));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedModels([]);
|
||||
setJudgeModel('');
|
||||
resetFilters();
|
||||
setError('');
|
||||
setShortAnswerScoreAnchors([]);
|
||||
setOpenEndedScoreAnchors([]);
|
||||
};
|
||||
|
||||
return {
|
||||
models,
|
||||
selectedModels,
|
||||
setSelectedModels,
|
||||
judgeModel,
|
||||
setJudgeModel,
|
||||
evalDatasets,
|
||||
availableTags,
|
||||
questionTypes,
|
||||
setQuestionTypes,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
questionCount,
|
||||
setQuestionCount,
|
||||
filteredTotal,
|
||||
sampledIds,
|
||||
hasSubjectiveQuestions,
|
||||
hasShortAnswer,
|
||||
hasOpenEnded,
|
||||
shortAnswerScoreAnchors,
|
||||
setShortAnswerScoreAnchors,
|
||||
openEndedScoreAnchors,
|
||||
setOpenEndedScoreAnchors,
|
||||
initScoreAnchors,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
setSampledIds,
|
||||
resetFilters,
|
||||
resetForm
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 评估任务列表 Hook
|
||||
*/
|
||||
export default function useEvalTasks(projectId) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = useCallback(
|
||||
async (isRefresh = false) => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
if (!isRefresh) setLoading(true);
|
||||
setError('');
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-tasks?page=${page}&pageSize=${pageSize}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setTasks(result.data.items || []);
|
||||
setTotal(result.data.total || 0);
|
||||
} else {
|
||||
setError(result.error || '加载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载评估任务失败:', err);
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
if (!isRefresh) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, page, pageSize]
|
||||
);
|
||||
|
||||
// 初始加载和分页变化加载
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// 自动刷新进行中的任务
|
||||
useEffect(() => {
|
||||
const hasProcessingTasks = tasks.some(t => t.status === 0);
|
||||
if (!hasProcessingTasks) return;
|
||||
|
||||
const interval = setInterval(() => loadTasks(true), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [tasks, loadTasks]);
|
||||
|
||||
// 删除任务
|
||||
const deleteTask = useCallback(
|
||||
async taskId => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '删除失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('删除任务失败:', err);
|
||||
setError('删除失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
// 中断任务
|
||||
const interruptTask = useCallback(
|
||||
async taskId => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'interrupt' })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '中断失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('中断任务失败:', err);
|
||||
setError('中断失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
// 创建任务
|
||||
const createTasks = useCallback(
|
||||
async data => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建任务失败:', err);
|
||||
return { success: false, error: '创建失败' };
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadTasks,
|
||||
deleteTask,
|
||||
interruptTask,
|
||||
createTasks,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
total
|
||||
};
|
||||
}
|
||||
188
easy-dataset-main/app/projects/[projectId]/eval-tasks/page.js
Normal file
188
easy-dataset-main/app/projects/[projectId]/eval-tasks/page.js
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Paper,
|
||||
Button,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
TablePagination
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useEvalTasks from './hooks/useEvalTasks';
|
||||
import CreateEvalTaskDialog from './components/CreateEvalTaskDialog';
|
||||
import EvalTaskCard from './components/EvalTaskCard';
|
||||
import styles from './styles';
|
||||
|
||||
export default function EvalTasksPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadTasks,
|
||||
deleteTask,
|
||||
interruptTask,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
total
|
||||
} = useEvalTasks(projectId);
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null });
|
||||
const [interruptDialog, setInterruptDialog] = useState({ open: false, task: null });
|
||||
|
||||
const handleView = task => router.push(`/projects/${projectId}/eval-tasks/${task.id}`);
|
||||
const handleDelete = task => setDeleteDialog({ open: true, task });
|
||||
const handleInterrupt = task => setInterruptDialog({ open: true, task });
|
||||
|
||||
const handlePageChange = (event, newPage) => {
|
||||
setPage(newPage + 1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = event => {
|
||||
setPageSize(parseInt(event.target.value, 10));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteDialog.task) {
|
||||
await deleteTask(deleteDialog.task.id);
|
||||
}
|
||||
setDeleteDialog({ open: false, task: null });
|
||||
};
|
||||
|
||||
const confirmInterrupt = async () => {
|
||||
if (interruptDialog.task) {
|
||||
await interruptTask(interruptDialog.task.id);
|
||||
}
|
||||
setInterruptDialog({ open: false, task: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={styles.pageContainer}>
|
||||
{/* 标题栏 */}
|
||||
<Box sx={styles.header}>
|
||||
<Typography variant="h5" sx={styles.headerTitle}>
|
||||
{t('evalTasks.title')}
|
||||
</Typography>
|
||||
<Box sx={styles.headerActions}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)}>
|
||||
{t('evalTasks.createTask')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && tasks.length === 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<Paper variant="outlined" sx={styles.emptyState}>
|
||||
<AssessmentIcon sx={styles.emptyIcon} />
|
||||
<Typography variant="h6" color="text.secondary" sx={styles.emptyTitle}>
|
||||
{t('evalTasks.noTasks')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled" sx={styles.emptyHint}>
|
||||
{t('evalTasks.noTasksHint')}
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)} size="large">
|
||||
{t('evalTasks.createTask')}
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* 任务列表 */}
|
||||
{tasks.length > 0 && (
|
||||
<>
|
||||
<Grid container spacing={2.5}>
|
||||
{tasks.map(task => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={task.id}>
|
||||
<EvalTaskCard task={task} onView={handleView} onDelete={handleDelete} onInterrupt={handleInterrupt} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page - 1}
|
||||
onPageChange={handlePageChange}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={handlePageSizeChange}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
labelRowsPerPage={t('datasets.rowsPerPage', '每页行数')}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建任务对话框 */}
|
||||
<CreateEvalTaskDialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={loadTasks}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, task: null })}>
|
||||
<DialogTitle>{t('evalTasks.deleteConfirmTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t('evalTasks.deleteConfirmMessage')}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, task: null })}>{t('common.cancel')}</Button>
|
||||
<Button onClick={confirmDelete} color="error" variant="contained">
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 中断确认对话框 */}
|
||||
<Dialog open={interruptDialog.open} onClose={() => setInterruptDialog({ open: false, task: null })}>
|
||||
<DialogTitle>{t('evalTasks.interruptConfirmTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t('evalTasks.interruptConfirmMessage')}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setInterruptDialog({ open: false, task: null })}>{t('common.cancel')}</Button>
|
||||
<Button onClick={confirmInterrupt} color="warning" variant="contained">
|
||||
{t('evalTasks.interrupt')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
280
easy-dataset-main/app/projects/[projectId]/eval-tasks/styles.js
Normal file
280
easy-dataset-main/app/projects/[projectId]/eval-tasks/styles.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 评估任务页面样式
|
||||
*/
|
||||
|
||||
export const evalTasksStyles = {
|
||||
// 页面容器
|
||||
pageContainer: {
|
||||
py: 3,
|
||||
minHeight: '100vh'
|
||||
},
|
||||
|
||||
// 页头
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 3
|
||||
},
|
||||
|
||||
headerTitle: {
|
||||
fontWeight: 600
|
||||
},
|
||||
|
||||
headerActions: {
|
||||
display: 'flex',
|
||||
gap: 1
|
||||
},
|
||||
|
||||
// 空状态
|
||||
emptyState: {
|
||||
p: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 400,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'background.paper'
|
||||
},
|
||||
|
||||
emptyIcon: {
|
||||
fontSize: 80,
|
||||
color: 'text.disabled',
|
||||
mb: 2
|
||||
},
|
||||
|
||||
emptyTitle: {
|
||||
mb: 1,
|
||||
fontWeight: 500
|
||||
},
|
||||
|
||||
emptyHint: {
|
||||
mb: 4,
|
||||
textAlign: 'center',
|
||||
maxWidth: 400
|
||||
},
|
||||
|
||||
// 任务卡片
|
||||
taskCard: theme => ({
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[6],
|
||||
transform: 'translateY(-4px)',
|
||||
borderColor: theme.palette.primary.main
|
||||
}
|
||||
}),
|
||||
|
||||
taskCardContent: {
|
||||
p: 2.5
|
||||
},
|
||||
|
||||
taskCardHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
mb: 2
|
||||
},
|
||||
|
||||
taskCardModel: {
|
||||
flex: 1,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
|
||||
taskCardModelName: {
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.3
|
||||
},
|
||||
|
||||
taskCardTime: {
|
||||
mt: 0.5,
|
||||
fontSize: '0.75rem'
|
||||
},
|
||||
|
||||
taskCardStatus: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 2
|
||||
},
|
||||
|
||||
taskCardProgress: {
|
||||
mb: 2
|
||||
},
|
||||
|
||||
progressBar: {
|
||||
height: 6,
|
||||
borderRadius: 3
|
||||
},
|
||||
|
||||
taskCardStats: {
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
|
||||
// 统计卡片
|
||||
statsCard: theme => ({
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[2]
|
||||
}
|
||||
}),
|
||||
|
||||
statsCardContent: {
|
||||
p: 2.5
|
||||
},
|
||||
|
||||
statsLabel: {
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary',
|
||||
mb: 1,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
|
||||
statsValue: {
|
||||
fontWeight: 700,
|
||||
fontSize: '1.75rem',
|
||||
lineHeight: 1.2
|
||||
},
|
||||
|
||||
// 按题型统计
|
||||
typeStatsContainer: {
|
||||
p: 2.5,
|
||||
mb: 3,
|
||||
borderRadius: 2
|
||||
},
|
||||
|
||||
typeStatsTitle: {
|
||||
fontWeight: 600,
|
||||
mb: 2
|
||||
},
|
||||
|
||||
typeStatsItem: theme => ({
|
||||
textAlign: 'center',
|
||||
p: 1.5,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
|
||||
borderRadius: 1.5,
|
||||
border: `1px solid ${theme.palette.divider}`
|
||||
}),
|
||||
|
||||
typeStatsLabel: {
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.secondary',
|
||||
mb: 0.5
|
||||
},
|
||||
|
||||
typeStatsScore: {
|
||||
fontWeight: 700,
|
||||
fontSize: '1.1rem'
|
||||
},
|
||||
|
||||
typeStatsPercent: {
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.secondary'
|
||||
},
|
||||
|
||||
// 结果表格
|
||||
resultsTable: {
|
||||
overflow: 'hidden',
|
||||
borderRadius: 2
|
||||
},
|
||||
|
||||
resultsTableHeader: {
|
||||
fontWeight: 600,
|
||||
p: 2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
},
|
||||
|
||||
resultsTableContainer: {
|
||||
maxHeight: 600
|
||||
},
|
||||
|
||||
resultRow: {
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
},
|
||||
|
||||
resultQuestion: {
|
||||
maxWidth: 400
|
||||
},
|
||||
|
||||
resultScore: correct => ({
|
||||
fontWeight: 'bold',
|
||||
color: correct ? 'success.main' : 'error.main'
|
||||
}),
|
||||
|
||||
resultExpandedContent: {
|
||||
py: 2.5,
|
||||
px: 1.5
|
||||
},
|
||||
|
||||
resultAnswerBox: isCorrect => theme => ({
|
||||
p: 2,
|
||||
mt: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: isCorrect
|
||||
? theme.palette.mode === 'dark'
|
||||
? 'rgba(46, 125, 50, 0.15)'
|
||||
: 'rgba(46, 125, 50, 0.08)'
|
||||
: theme.palette.mode === 'dark'
|
||||
? 'rgba(211, 47, 47, 0.15)'
|
||||
: 'rgba(211, 47, 47, 0.08)',
|
||||
border: `1px solid ${isCorrect ? theme.palette.success.main : theme.palette.error.main}`
|
||||
}),
|
||||
|
||||
resultReferenceBox: {
|
||||
p: 2,
|
||||
mt: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover'
|
||||
},
|
||||
|
||||
resultJudgeBox: {
|
||||
p: 2,
|
||||
mt: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover'
|
||||
},
|
||||
|
||||
// 对话框
|
||||
dialogContent: {
|
||||
mt: 1
|
||||
},
|
||||
|
||||
dialogSection: {
|
||||
mb: 3
|
||||
},
|
||||
|
||||
dialogDivider: {
|
||||
my: 2
|
||||
},
|
||||
|
||||
dialogInfoBox: theme => ({
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
|
||||
borderRadius: 1.5,
|
||||
border: `1px solid ${theme.palette.divider}`
|
||||
}),
|
||||
|
||||
dialogWarning: {
|
||||
mt: 1,
|
||||
color: 'warning.main',
|
||||
fontWeight: 500
|
||||
}
|
||||
};
|
||||
|
||||
export default evalTasksStyles;
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Box, CircularProgress, Alert } from '@mui/material';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useImageDatasetDetails from '../hooks/useImageDatasetDetails';
|
||||
import ImageDatasetHeader from '../components/ImageDatasetHeader';
|
||||
import DatasetContent from '../components/DatasetContent';
|
||||
import DatasetSidebar from '../components/DatasetSidebar';
|
||||
|
||||
export default function ImageDatasetDetailPage() {
|
||||
const { projectId, datasetId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
currentDataset,
|
||||
loading,
|
||||
confirming,
|
||||
unconfirming,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
updateDataset,
|
||||
handleNavigate,
|
||||
handleConfirm,
|
||||
handleUnconfirm,
|
||||
handleDelete
|
||||
} = useImageDatasetDetails(projectId, datasetId);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据状态
|
||||
if (!currentDataset) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{t('imageDatasets.notFound', '数据集不存在')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* 顶部导航栏 */}
|
||||
<ImageDatasetHeader
|
||||
projectId={projectId}
|
||||
datasetsAllCount={datasetsAllCount}
|
||||
datasetsConfirmCount={datasetsConfirmCount}
|
||||
confirming={confirming}
|
||||
unconfirming={unconfirming}
|
||||
currentDataset={currentDataset}
|
||||
onNavigate={handleNavigate}
|
||||
onConfirm={handleConfirm}
|
||||
onUnconfirm={handleUnconfirm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 主要布局:左右分栏 */}
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* 左侧主要内容区域 */}
|
||||
<DatasetContent
|
||||
dataset={currentDataset}
|
||||
projectId={projectId}
|
||||
onAnswerChange={async newAnswer => {
|
||||
// 直接传递答案字符串,DatasetContent 已经处理了格式转换
|
||||
await updateDataset({ answer: newAnswer });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 右侧固定侧边栏 */}
|
||||
<DatasetSidebar dataset={currentDataset} projectId={projectId} onUpdate={updateDataset} />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Paper, Typography, Button } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Image from 'next/image';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import AnswerInput from '../../images/components/annotation/AnswerInput';
|
||||
|
||||
function handleAnswer(dataset) {
|
||||
const { answer, answerType } = dataset;
|
||||
if (answerType === 'label' || answerType === 'custom_format') {
|
||||
try {
|
||||
return JSON.parse(answer);
|
||||
} catch (e) {
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据集主要内容组件
|
||||
*/
|
||||
export default function DatasetContent({ dataset, projectId, onAnswerChange }) {
|
||||
const { t } = useTranslation();
|
||||
const [currentAnswer, setCurrentAnswer] = useState(() => handleAnswer(dataset));
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 当 dataset 变化时,重置状态
|
||||
useEffect(() => {
|
||||
setCurrentAnswer(handleAnswer(dataset));
|
||||
setHasChanges(false);
|
||||
}, [dataset.id, dataset.answer]);
|
||||
|
||||
// 处理答案变化
|
||||
const handleAnswerChange = newAnswer => {
|
||||
setCurrentAnswer(newAnswer);
|
||||
|
||||
// 检测是否有变化
|
||||
const originalAnswer = handleAnswer(dataset);
|
||||
const hasChanged = JSON.stringify(newAnswer) !== JSON.stringify(originalAnswer);
|
||||
setHasChanges(hasChanged);
|
||||
};
|
||||
|
||||
// 保存答案
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
let answerToSave = currentAnswer;
|
||||
if (typeof answerToSave !== 'string') {
|
||||
answerToSave = JSON.stringify(answerToSave, null, 2);
|
||||
}
|
||||
await onAnswerChange(answerToSave);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{/* 问题和保存按钮 */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.7,
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'grey.100',
|
||||
p: 2,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{dataset.question}
|
||||
</Typography>
|
||||
|
||||
{/* 保存按钮 - 只在有变化时显示 */}
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
sx={{
|
||||
minWidth: 100,
|
||||
height: 'fit-content',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? t('common.saving', '保存中...') : t('common.save', '保存')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 答案编辑器 */}
|
||||
<AnswerInput
|
||||
answerType={dataset.answerType || 'text'}
|
||||
answer={currentAnswer}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
labels={dataset.availableLabels || []}
|
||||
customFormat={dataset.customFormat}
|
||||
projectId={projectId}
|
||||
imageName={dataset.imageName}
|
||||
question={dataset.questionData}
|
||||
/>
|
||||
|
||||
{/* 图片 */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
paddingTop: '56.25%',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'grey.100',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{dataset.base64 ? (
|
||||
<img
|
||||
src={dataset.base64}
|
||||
alt={dataset.imageName}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image src="/placeholder.png" alt={dataset.imageName} fill style={{ objectFit: 'contain' }} unoptimized />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, textAlign: 'center' }}>
|
||||
{dataset.imageName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
import MetadataInfo from './MetadataInfo';
|
||||
import MetadataEditor from './MetadataEditor';
|
||||
|
||||
/**
|
||||
* 数据集右侧边栏组件
|
||||
*/
|
||||
export default function DatasetSidebar({ dataset, projectId, onUpdate }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 360,
|
||||
position: 'sticky',
|
||||
top: 24,
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* 元数据信息 - Chip 形式 */}
|
||||
<MetadataInfo dataset={dataset} />
|
||||
|
||||
{/* 操作卡片 */}
|
||||
<MetadataEditor dataset={dataset} projectId={projectId} onUpdate={onUpdate} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imageDatasetStyles } from '../styles/imageDatasetStyles';
|
||||
|
||||
export default function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={imageDatasetStyles.emptyState}>
|
||||
<Box sx={imageDatasetStyles.emptyIcon}>
|
||||
<ImageSearchIcon sx={{ fontSize: 60, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={imageDatasetStyles.emptyTitle}>
|
||||
{t('imageDatasets.noData', { defaultValue: '暂无图片数据集' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={imageDatasetStyles.emptyDescription}>
|
||||
{t('imageDatasets.noDataTip', { defaultValue: '请先在图片管理中生成问答数据集' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
Checkbox,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
|
||||
const ExportImageDatasetDialog = ({ open, onClose, onExport }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formatType, setFormatType] = useState('raw');
|
||||
const [exportImages, setExportImages] = useState(false);
|
||||
const [includeImagePath, setIncludeImagePath] = useState(true);
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [confirmedOnly, setConfirmedOnly] = useState(false);
|
||||
|
||||
const handleExport = () => {
|
||||
onExport({
|
||||
formatType,
|
||||
exportImages,
|
||||
includeImagePath,
|
||||
systemPrompt,
|
||||
confirmedOnly
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('imageDatasets.exportTitle', '导出图片数据集')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 2 }}>
|
||||
{/* 导出格式选择 */}
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">{t('imageDatasets.exportFormat', '导出格式')}</FormLabel>
|
||||
<RadioGroup value={formatType} onChange={e => setFormatType(e.target.value)}>
|
||||
<FormControlLabel value="raw" control={<Radio />} label={t('imageDatasets.rawFormat', '原始格式')} />
|
||||
<FormControlLabel value="sharegpt" control={<Radio />} label="ShareGPT (OpenAI)" />
|
||||
<FormControlLabel value="alpaca" control={<Radio />} label="Alpaca" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{/* 图片导出选项 */}
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={exportImages} onChange={e => setExportImages(e.target.checked)} />}
|
||||
label={t('imageDatasets.exportImagesOption', '导出图片文件')}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 4 }}>
|
||||
{t('imageDatasets.exportImagesDesc', '将所有图片打包成 ZIP 压缩包一起下载')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 图片路径选项 */}
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={includeImagePath} onChange={e => setIncludeImagePath(e.target.checked)} />}
|
||||
label={t('imageDatasets.includeImagePath', '在数据集中包含图片路径')}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 4 }}>
|
||||
{t('imageDatasets.includeImagePathDesc', '在问题或答案中添加图片路径(格式:/images/图片名称)')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 系统提示词 */}
|
||||
<TextField
|
||||
label={t('imageDatasets.systemPrompt', '系统提示词(可选)')}
|
||||
multiline
|
||||
rows={3}
|
||||
value={systemPrompt}
|
||||
onChange={e => setSystemPrompt(e.target.value)}
|
||||
placeholder={t('imageDatasets.systemPromptPlaceholder', '输入系统提示词...')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* 仅导出已确认 */}
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={confirmedOnly} onChange={e => setConfirmedOnly(e.target.checked)} />}
|
||||
label={t('imageDatasets.confirmedOnly', '仅导出已确认的数据集')}
|
||||
/>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
{t('imageDatasets.exportTip', '标签格式的答案将自动解析为文本(逗号分隔)')}
|
||||
</Alert>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleClose} variant="outlined">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button onClick={handleExport} variant="contained">
|
||||
{t('export.title', '导出')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportImageDatasetDialog;
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardMedia, Box, Chip, Typography, Tooltip, IconButton } from '@mui/material';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imageDatasetStyles } from '../styles/imageDatasetStyles';
|
||||
|
||||
export default function ImageDatasetCard({
|
||||
dataset,
|
||||
onClick,
|
||||
onView = () => {},
|
||||
onDelete = () => {},
|
||||
onEvaluate = () => {}
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getAnswerText = () => {
|
||||
if (!dataset.answer) return t('imageDatasets.noAnswer', '暂无答案');
|
||||
if (dataset.answerType === 'label') {
|
||||
try {
|
||||
const labels = JSON.parse(dataset.answer);
|
||||
return `${t('imageDatasets.labels', '标签')}: ${labels.join(', ')}`;
|
||||
} catch {
|
||||
return dataset.answer;
|
||||
}
|
||||
}
|
||||
return dataset.answer;
|
||||
};
|
||||
|
||||
const getAnswerTypeLabel = type => {
|
||||
switch (type) {
|
||||
case 'label':
|
||||
return t('imageDatasets.typeLabel', '标签');
|
||||
case 'custom_format':
|
||||
return t('imageDatasets.typeCustom', '自定义');
|
||||
default:
|
||||
return t('imageDatasets.typeText', '文本');
|
||||
}
|
||||
};
|
||||
|
||||
const getAnswerTypeColor = type => {
|
||||
switch (type) {
|
||||
case 'label':
|
||||
return 'secondary';
|
||||
case 'custom_format':
|
||||
return 'info';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreLabel = () => {
|
||||
if (!dataset.score || dataset.score === 0) {
|
||||
return t('imageDatasets.unscored', '未评分');
|
||||
}
|
||||
return dataset.score;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={imageDatasetStyles.datasetCard}>
|
||||
{/* 图片区域 */}
|
||||
<Box sx={imageDatasetStyles.imageWrapper}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={dataset.base64 || '/placeholder.png'}
|
||||
alt={dataset.imageName}
|
||||
sx={imageDatasetStyles.imageMedia}
|
||||
/>
|
||||
|
||||
{/* 悬停遮罩 */}
|
||||
<Box sx={imageDatasetStyles.imageOverlay} />
|
||||
|
||||
{/* 问题内容 - 底部,毛玻璃背景 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
p: 1.5,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.6) 70%, transparent 100%)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.5)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{dataset.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 内容区域 - 标签和操作按钮 */}
|
||||
<Tooltip title={getAnswerText()} placement="top" arrow>
|
||||
<Box sx={{ p: 1.5, cursor: 'help' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
{/* 左侧:所有标签 */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={getAnswerTypeLabel(dataset.answerType)}
|
||||
size="small"
|
||||
color={getAnswerTypeColor(dataset.answerType)}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
<Chip
|
||||
label={
|
||||
dataset.confirmed ? t('imageDatasets.confirmed', '已确认') : t('imageDatasets.unconfirmed', '未确认')
|
||||
}
|
||||
size="small"
|
||||
color={dataset.confirmed ? 'success' : 'default'}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
<Chip
|
||||
icon={<span style={{ fontSize: '0.7rem' }}>⭐</span>}
|
||||
label={getScoreLabel()}
|
||||
size="small"
|
||||
color={dataset.score && dataset.score > 0 ? 'warning' : 'default'}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:操作按钮 - 不同颜色 */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={t('imageDatasets.view', '查看详情')} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onView(dataset.id);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: '#1976d2',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('imageDatasets.evaluate', '质量评估')} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEvaluate(dataset.id);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: '#f57c00',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(245, 124, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AssessmentIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('imageDatasets.delete', '删除')} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(dataset.id);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: '#d32f2f',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(211, 47, 47, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
Slider,
|
||||
TextField,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ImageDatasetFilterDialog({
|
||||
open,
|
||||
onClose,
|
||||
statusFilter,
|
||||
scoreFilter,
|
||||
onStatusChange,
|
||||
onScoreChange,
|
||||
onResetFilters,
|
||||
onApplyFilters
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('datasets.filtersTitle', '筛选条件')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* 确认状态筛选 */}
|
||||
<Box sx={{ mb: 3, mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{t('imageDatasets.status', { defaultValue: '确认状态' })}
|
||||
</Typography>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={e => onStatusChange(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem value="all">{t('common.all', '全部')}</MenuItem>
|
||||
<MenuItem value="confirmed">{t('imageDatasets.confirmed', { defaultValue: '已确认' })}</MenuItem>
|
||||
<MenuItem value="unconfirmed">{t('imageDatasets.unconfirmed', { defaultValue: '未确认' })}</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
{/* 评分范围筛选 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{t('imageDatasets.scoreRange', { defaultValue: '评分范围' })}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
{scoreFilter[0]} - {scoreFilter[1]} 分
|
||||
</Typography>
|
||||
<Slider
|
||||
value={scoreFilter}
|
||||
onChange={(e, newValue) => onScoreChange(newValue)}
|
||||
valueLabelDisplay="auto"
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
marks
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
{/* 对话框操作按钮 */}
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onResetFilters} variant="outlined">
|
||||
{t('common.reset', '重置')}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button onClick={onApplyFilters} variant="contained">
|
||||
{t('common.apply', '应用')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Paper, IconButton, InputBase, Button, Badge } from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ImageDatasetFilters({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onMoreFiltersClick,
|
||||
activeFilterCount = 0
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 搜索框 - 完全参考数据集管理的设计 */}
|
||||
<Paper
|
||||
component="form"
|
||||
sx={{
|
||||
p: '2px 4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 400,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<IconButton sx={{ p: '10px' }} aria-label="search">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
<InputBase
|
||||
sx={{ ml: 1, flex: 1 }}
|
||||
placeholder={t('imageDatasets.searchPlaceholder', { defaultValue: '搜索问题或答案...' })}
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* 更多筛选按钮 - 带 Badge 显示活跃筛选条件数 */}
|
||||
<Badge badgeContent={activeFilterCount} color="error" overlap="circular">
|
||||
<Button variant="outlined" onClick={onMoreFiltersClick} startIcon={<FilterListIcon />} sx={{ borderRadius: 2 }}>
|
||||
{t('datasets.moreFilters', '更多筛选')}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Divider, Typography, IconButton, CircularProgress, Paper } from '@mui/material';
|
||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import UndoIcon from '@mui/icons-material/Undo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 图片数据集详情页面的头部导航组件
|
||||
*/
|
||||
export default function ImageDatasetHeader({
|
||||
projectId,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
confirming,
|
||||
unconfirming,
|
||||
currentDataset,
|
||||
onNavigate,
|
||||
onConfirm,
|
||||
onUnconfirm,
|
||||
onDelete
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{/* 左侧:返回按钮和统计信息 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<NavigateBeforeIcon />}
|
||||
onClick={() => router.push(`/projects/${projectId}/image-datasets`)}
|
||||
>
|
||||
{t('imageDatasets.title', '图片数据集')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
共 {datasetsAllCount} 个数据集,已确认 {datasetsConfirmCount} 个 (
|
||||
{datasetsAllCount > 0 ? ((datasetsConfirmCount / datasetsAllCount) * 100).toFixed(2) : 0}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:翻页、确认/取消确认、删除按钮 */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton onClick={() => onNavigate('prev')}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onNavigate('next')}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* 确认/取消确认按钮 */}
|
||||
{currentDataset?.confirmed ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={unconfirming}
|
||||
onClick={onUnconfirm}
|
||||
startIcon={unconfirming ? <CircularProgress size={16} /> : <UndoIcon />}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{unconfirming ? t('common.unconfirming', '取消中...') : t('datasets.unconfirm', '取消确认')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" color="primary" disabled={confirming} onClick={onConfirm} sx={{ mr: 1 }}>
|
||||
{confirming ? <CircularProgress size={24} /> : t('datasets.confirmSave', '确认保留')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={onDelete}>
|
||||
{t('common.delete', '删除')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Divider, Paper } from '@mui/material';
|
||||
import { toast } from 'sonner';
|
||||
import StarRating from '@/components/datasets/StarRating';
|
||||
import TagSelector from '@/components/datasets/TagSelector';
|
||||
import NoteInput from '@/components/datasets/NoteInput';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function MetadataEditor({ dataset, projectId, onUpdate }) {
|
||||
const { t } = useTranslation();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 解析数据集中的标签
|
||||
const parseDatasetTags = tagsString => {
|
||||
try {
|
||||
return JSON.parse(tagsString || '[]');
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 本地状态管理,从 props 初始化
|
||||
const [localScore, setLocalScore] = useState(dataset?.score || 0);
|
||||
const [localTags, setLocalTags] = useState(() => {
|
||||
const tags = parseDatasetTags(dataset?.tags);
|
||||
// 确保 localTags 始终是数组
|
||||
return Array.isArray(tags) ? tags : [];
|
||||
});
|
||||
const [localNote, setLocalNote] = useState(dataset?.note || '');
|
||||
|
||||
// 获取项目中已使用的标签
|
||||
useEffect(() => {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/image-datasets/tags`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableTags(data.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取可用标签失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 同步props中的dataset到本地状态
|
||||
useEffect(() => {
|
||||
if (dataset) {
|
||||
setLocalScore(dataset.score || 0);
|
||||
const tags = parseDatasetTags(dataset.tags);
|
||||
setLocalTags(Array.isArray(tags) ? tags : []);
|
||||
setLocalNote(dataset.note || '');
|
||||
}
|
||||
}, [dataset]);
|
||||
|
||||
// 更新数据集元数据
|
||||
const updateMetadata = async updates => {
|
||||
if (loading) return;
|
||||
|
||||
// 立即更新本地状态,提升响应速度
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(updates.score);
|
||||
}
|
||||
// 注意:tags 已经在 handleTagsChange 中更新过了,这里不需要再更新
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(updates.note);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 调用父组件的更新方法
|
||||
if (onUpdate) {
|
||||
await onUpdate(updates);
|
||||
}
|
||||
toast.success(t('imageDatasets.updateSuccess', '更新成功'));
|
||||
} catch (error) {
|
||||
console.error('更新数据集元数据失败:', error);
|
||||
toast.error(t('imageDatasets.updateFailed', '更新失败'));
|
||||
|
||||
// 出错时恢复本地状态
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(dataset?.score || 0);
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
// 恢复为原始的标签数组
|
||||
const tags = parseDatasetTags(dataset?.tags);
|
||||
setLocalTags(Array.isArray(tags) ? tags : []);
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(dataset?.note || '');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理评分变更
|
||||
const handleScoreChange = newScore => {
|
||||
updateMetadata({ score: newScore });
|
||||
};
|
||||
|
||||
// 处理标签变更
|
||||
const handleTagsChange = newTags => {
|
||||
// 立即更新本地状态(保持为数组)
|
||||
setLocalTags(newTags);
|
||||
// 发送给父组件时转换为 JSON 字符串
|
||||
updateMetadata({ tags: JSON.stringify(newTags) });
|
||||
};
|
||||
|
||||
// 处理备注变更
|
||||
const handleNoteChange = newNote => {
|
||||
updateMetadata({ note: newNote });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* 评分区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.rating', '评分')}
|
||||
</Typography>
|
||||
<StarRating value={localScore} onChange={handleScoreChange} readOnly={loading} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 标签区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('datasets.customTags', '自定义标签')}
|
||||
</Typography>
|
||||
<TagSelector
|
||||
value={localTags}
|
||||
onChange={handleTagsChange}
|
||||
availableTags={availableTags}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addCustomTag', '添加自定义标签...')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 备注区域 */}
|
||||
<NoteInput
|
||||
value={localNote}
|
||||
onChange={handleNoteChange}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addNote', '添加备注...')}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Chip, alpha, Divider } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
/**
|
||||
* 元数据信息展示组件 - Chip 形式(参考 DatasetMetadata)
|
||||
*/
|
||||
export default function MetadataInfo({ dataset }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
// 解析标签
|
||||
const parsedTags = (() => {
|
||||
try {
|
||||
if (typeof dataset.tags === 'string' && dataset.tags) {
|
||||
return JSON.parse(dataset.tags);
|
||||
}
|
||||
return Array.isArray(dataset.tags) ? dataset.tags : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = bytes => {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{/* 数据集信息 */}
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('common.detailInfo', '详细信息')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 2 }}>
|
||||
{/* 使用模型 */}
|
||||
{dataset.model && (
|
||||
<Chip
|
||||
label={`${t('imageDatasets.modelInfo', '使用模型')}: ${dataset.model}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标签数量 */}
|
||||
{parsedTags.length > 0 && (
|
||||
<Chip
|
||||
label={`${t('imageDatasets.tags', '标签')}: ${parsedTags.length} ${t('common.items', '项')}`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 创建时间 */}
|
||||
<Chip
|
||||
label={`${t('imageDatasets.createdAt', '创建时间')}: ${new Date(dataset.createAt).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* 文本块信息 */}
|
||||
{dataset.questionTemplate?.description && (
|
||||
<Chip
|
||||
label={`${t('imageDatasets.description', '描述')}: ${dataset.questionTemplate.description}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ maxWidth: '100%' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 确认状态 */}
|
||||
{dataset.confirmed && (
|
||||
<Chip
|
||||
label={t('datasets.confirmed', '已确认')}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.success.main, 0.1),
|
||||
color: theme.palette.success.dark,
|
||||
fontWeight: 'medium'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 图片信息 */}
|
||||
{dataset.image && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('images.imageInfo', '图片信息')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
{/* 图片尺寸 */}
|
||||
{dataset.image.width && dataset.image.height && (
|
||||
<Chip
|
||||
label={`${t('images.resolution', '分辨率')}: ${dataset.image.width}×${dataset.image.height}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件大小 */}
|
||||
{dataset.image.size && (
|
||||
<Chip
|
||||
label={`${t('images.fileSize', '文件大小')}: ${formatFileSize(dataset.image.size)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片创建时间 */}
|
||||
{dataset.image.createAt && (
|
||||
<Chip
|
||||
label={`${t('images.uploadTime', '上传时间')}: ${new Date(dataset.image.createAt).toLocaleString(
|
||||
'zh-CN',
|
||||
{
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片名称 */}
|
||||
{dataset.image.imageName && (
|
||||
<Chip
|
||||
label={`${t('images.fileName', '文件名')}: ${dataset.image.imageName}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ maxWidth: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useImageDatasetDetail(projectId, datasetId) {
|
||||
const { t } = useTranslation();
|
||||
const [dataset, setDataset] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 获取详情
|
||||
const fetchDetail = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
setDataset(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dataset detail:', error);
|
||||
toast.error(t('imageDatasets.fetchDetailFailed', { defaultValue: '获取详情失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, datasetId, t]);
|
||||
|
||||
// 更新数据集
|
||||
const updateDataset = useCallback(
|
||||
async updates => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await axios.put(`/api/projects/${projectId}/image-datasets/${datasetId}`, updates);
|
||||
setDataset(response.data);
|
||||
toast.success(t('imageDatasets.updateSuccess', { defaultValue: '更新成功' }));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to update dataset:', error);
|
||||
toast.error(t('imageDatasets.updateFailed', { defaultValue: '更新失败' }));
|
||||
throw error;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[projectId, datasetId, t]
|
||||
);
|
||||
|
||||
// AI 重新识别
|
||||
const regenerateAnswer = useCallback(async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await axios.post(`/api/projects/${projectId}/image-datasets/${datasetId}/regenerate`);
|
||||
setDataset(response.data);
|
||||
toast.success(t('imageDatasets.regenerateSuccess', { defaultValue: 'AI 识别成功' }));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate answer:', error);
|
||||
toast.error(t('imageDatasets.regenerateFailed', { defaultValue: 'AI 识别失败' }));
|
||||
throw error;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [projectId, datasetId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && datasetId) {
|
||||
fetchDetail();
|
||||
}
|
||||
}, [projectId, datasetId, fetchDetail]);
|
||||
|
||||
return {
|
||||
dataset,
|
||||
loading,
|
||||
saving,
|
||||
updateDataset,
|
||||
regenerateAnswer,
|
||||
fetchDetail
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function useImageDatasetDetails(projectId, datasetId) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [currentDataset, setCurrentDataset] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [unconfirming, setUnconfirming] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [datasetsAllCount, setDatasetsAllCount] = useState(0);
|
||||
const [datasetsConfirmCount, setDatasetsConfirmCount] = useState(0);
|
||||
|
||||
// 获取数据集列表信息
|
||||
const fetchDatasetsList = useCallback(async () => {
|
||||
try {
|
||||
// 获取所有数据集以正确统计已确认数量
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets?page=1&pageSize=10000`);
|
||||
const data = response.data;
|
||||
setDatasetsAllCount(data.total || 0);
|
||||
setDatasetsConfirmCount(data.data?.filter(d => d.confirmed).length || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch datasets list:', error);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 获取当前数据集详情
|
||||
const fetchDatasetDetail = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
setCurrentDataset(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dataset detail:', error);
|
||||
toast.error(t('imageDatasets.fetchDetailFailed', '获取详情失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, datasetId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && datasetId) {
|
||||
fetchDatasetDetail();
|
||||
fetchDatasetsList();
|
||||
}
|
||||
}, [projectId, datasetId, fetchDatasetDetail, fetchDatasetsList]);
|
||||
|
||||
// 更新数据集
|
||||
const updateDataset = useCallback(
|
||||
async updates => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await axios.put(`/api/projects/${projectId}/image-datasets/${datasetId}`, updates);
|
||||
toast.success(t('imageDatasets.updateSuccess', '更新成功'));
|
||||
// 刷新数据
|
||||
await fetchDatasetDetail();
|
||||
await fetchDatasetsList();
|
||||
} catch (error) {
|
||||
console.error('Failed to update dataset:', error);
|
||||
toast.error(t('imageDatasets.updateFailed', '更新失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[projectId, datasetId, t, fetchDatasetDetail, fetchDatasetsList]
|
||||
);
|
||||
|
||||
// 翻页导航
|
||||
const handleNavigate = useCallback(
|
||||
async (direction, skipCurrentId = null) => {
|
||||
try {
|
||||
// 获取所有数据集(不分页),使用一个足够大的 pageSize
|
||||
const response = await axios.get(`/api/projects/${projectId}/image-datasets?page=1&pageSize=10000`);
|
||||
const datasets = response.data.data || [];
|
||||
|
||||
if (datasets.length === 0) {
|
||||
router.push(`/projects/${projectId}/image-datasets`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确定当前索引
|
||||
let currentIndex = -1;
|
||||
const searchId = skipCurrentId || datasetId;
|
||||
const currentDatasetId = String(searchId);
|
||||
|
||||
// 查找当前数据集的索引
|
||||
currentIndex = datasets.findIndex(d => String(d.id) === currentDatasetId);
|
||||
|
||||
// 如果找不到(删除场景或其他原因),从第一个开始
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
// 计算下一个索引
|
||||
let nextIndex;
|
||||
if (direction === 'prev') {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : datasets.length - 1;
|
||||
} else {
|
||||
nextIndex = currentIndex < datasets.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
const nextDataset = datasets[nextIndex];
|
||||
if (nextDataset) {
|
||||
router.push(`/projects/${projectId}/image-datasets/${nextDataset.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate:', error);
|
||||
toast.error(t('common.navigationFailed', '导航失败'));
|
||||
}
|
||||
},
|
||||
[projectId, datasetId, router, t]
|
||||
);
|
||||
|
||||
// 确认保留
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setConfirming(true);
|
||||
try {
|
||||
await updateDataset({ confirmed: true });
|
||||
// 确认后导航到下一条
|
||||
await handleNavigate('next');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}, [updateDataset, handleNavigate]);
|
||||
|
||||
// 取消确认
|
||||
const handleUnconfirm = useCallback(async () => {
|
||||
setUnconfirming(true);
|
||||
try {
|
||||
await updateDataset({ confirmed: false });
|
||||
} finally {
|
||||
setUnconfirming(false);
|
||||
}
|
||||
}, [updateDataset]);
|
||||
|
||||
// 删除数据集
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (confirm(t('imageDatasets.deleteConfirm', '确定要删除这个数据集吗?'))) {
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
toast.success(t('imageDatasets.deleteSuccess', '删除成功'));
|
||||
// 导航到下一条,传递 datasetId 以便 handleNavigate 知道是删除场景
|
||||
await handleNavigate('next', datasetId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dataset:', error);
|
||||
toast.error(t('imageDatasets.deleteFailed', '删除失败'));
|
||||
}
|
||||
}
|
||||
}, [projectId, datasetId, handleNavigate, t]);
|
||||
|
||||
return {
|
||||
currentDataset,
|
||||
loading,
|
||||
saving,
|
||||
confirming,
|
||||
unconfirming,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
updateDataset,
|
||||
handleNavigate,
|
||||
handleConfirm,
|
||||
handleUnconfirm,
|
||||
handleDelete
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
const useImageDatasetExport = projectId => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* 解析标签格式的答案
|
||||
* 如果答案是 JSON 数组格式,解析并用逗号连接
|
||||
*/
|
||||
const parseAnswerLabels = item => {
|
||||
const { answer, answerType } = item;
|
||||
if (answerType !== 'label' || !answer) {
|
||||
return answer;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析 JSON
|
||||
const parsed = JSON.parse(answer);
|
||||
if (Array.isArray(parsed)) {
|
||||
// 如果是数组,用逗号连接
|
||||
return parsed.join(', ');
|
||||
}
|
||||
return answer;
|
||||
} catch (e) {
|
||||
// 不是 JSON 格式,直接返回原答案
|
||||
return answer;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出图片数据集
|
||||
*/
|
||||
const exportImageDatasets = async exportOptions => {
|
||||
try {
|
||||
// 1. 获取数据集数据
|
||||
const apiUrl = `/api/projects/${projectId}/image-datasets/export`;
|
||||
const response = await axios.post(apiUrl, {
|
||||
confirmedOnly: exportOptions.confirmedOnly
|
||||
});
|
||||
|
||||
let datasets = response.data;
|
||||
|
||||
if (!datasets || datasets.length === 0) {
|
||||
toast.warning(t('imageDatasets.noDataToExport', '没有可导出的数据'));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 处理答案中的标签格式
|
||||
datasets = datasets.map(item => ({
|
||||
...item,
|
||||
answer: parseAnswerLabels(item)
|
||||
}));
|
||||
|
||||
// 3. 根据格式类型转换数据
|
||||
let formattedData;
|
||||
|
||||
if (exportOptions.formatType === 'raw') {
|
||||
// 原始格式:直接导出数据集
|
||||
formattedData = datasets.map(item => {
|
||||
const result = { ...item };
|
||||
|
||||
// 如果需要包含图片路径
|
||||
if (exportOptions.includeImagePath && item.imageName) {
|
||||
result.image_path = `/images/${item.imageName}`;
|
||||
}
|
||||
|
||||
if (item.answerType === 'custom_format') {
|
||||
try {
|
||||
result.answerObj = JSON.parse(item.answer);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
} else if (exportOptions.formatType === 'alpaca') {
|
||||
formattedData = datasets.map(({ question, answer, imageName }) => {
|
||||
const item = {
|
||||
instruction: question,
|
||||
input: '',
|
||||
output: answer
|
||||
};
|
||||
|
||||
// 如果需要包含图片路径
|
||||
if (exportOptions.includeImagePath && imageName) {
|
||||
item.images = [`/images/${imageName}`];
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
} else if (exportOptions.formatType === 'sharegpt') {
|
||||
formattedData = datasets.map(({ question, answer, imageName }) => {
|
||||
const messages = [];
|
||||
|
||||
// 添加系统提示词(如果有)
|
||||
if (exportOptions.systemPrompt) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: exportOptions.systemPrompt
|
||||
});
|
||||
}
|
||||
|
||||
// 添加用户问题
|
||||
const userContent = [];
|
||||
|
||||
// 如果需要包含图片路径
|
||||
if (exportOptions.includeImagePath && imageName) {
|
||||
userContent.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `/images/${imageName}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
userContent.push({
|
||||
type: 'text',
|
||||
text: question
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: userContent
|
||||
});
|
||||
|
||||
// 添加助手回答
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: answer
|
||||
});
|
||||
|
||||
return { messages };
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 生成 JSON 文件
|
||||
const jsonContent = JSON.stringify(formattedData, null, 2);
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
const formatSuffix = exportOptions.formatType;
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
a.download = `image-datasets-${projectId}-${formatSuffix}-${dateStr}.json`;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(t('imageDatasets.exportSuccess', '数据集导出成功'));
|
||||
|
||||
// 5. 如果需要导出图片,调用压缩包接口
|
||||
if (exportOptions.exportImages) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
confirmedOnly: exportOptions.confirmedOnly.toString()
|
||||
});
|
||||
|
||||
const zipUrl = `/api/projects/${projectId}/image-datasets/export-zip?${params.toString()}`;
|
||||
|
||||
// 创建一个隐藏的 a 标签来触发下载
|
||||
const a = document.createElement('a');
|
||||
a.href = zipUrl;
|
||||
a.style.display = 'none';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t('imageDatasets.exportImagesSuccess', '图片压缩包导出成功'));
|
||||
} catch (error) {
|
||||
console.error('Failed to export images:', error);
|
||||
toast.error(t('imageDatasets.exportImagesFailed', '图片导出失败'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
toast.error(error.message || t('imageDatasets.exportFailed', '导出失败'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
exportImageDatasets
|
||||
};
|
||||
};
|
||||
|
||||
export default useImageDatasetExport;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'imageDatasetFilters';
|
||||
|
||||
export function useImageDatasetFilters(projectId) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [scoreFilter, setScoreFilter] = useState([0, 5]);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// 从 localStorage 恢复筛选条件
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(`${STORAGE_KEY}_${projectId}`);
|
||||
if (stored) {
|
||||
const filters = JSON.parse(stored);
|
||||
setSearchQuery(filters.searchQuery || '');
|
||||
setStatusFilter(filters.statusFilter || 'all');
|
||||
setScoreFilter(filters.scoreFilter || [0, 5]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore filters:', error);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, [projectId]);
|
||||
|
||||
// 保存筛选条件到 localStorage
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${STORAGE_KEY}_${projectId}`,
|
||||
JSON.stringify({
|
||||
searchQuery,
|
||||
statusFilter,
|
||||
scoreFilter
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save filters:', error);
|
||||
}
|
||||
}
|
||||
}, [projectId, searchQuery, statusFilter, scoreFilter, isInitialized]);
|
||||
|
||||
// 计算活跃筛选条件数
|
||||
const getActiveFilterCount = useCallback(() => {
|
||||
let count = 0;
|
||||
if (statusFilter !== 'all') count++;
|
||||
if (scoreFilter[0] > 0 || scoreFilter[1] < 5) count++;
|
||||
return count;
|
||||
}, [statusFilter, scoreFilter]);
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
setStatusFilter('all');
|
||||
setScoreFilter([0, 5]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
scoreFilter,
|
||||
setScoreFilter,
|
||||
isInitialized,
|
||||
getActiveFilterCount,
|
||||
resetFilters
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useImageDatasets(projectId, filters = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [datasets, setDatasets] = useState({ data: [], total: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 使用 useMemo 稳定 filters 对象引用
|
||||
const stableFilters = useMemo(
|
||||
() => ({
|
||||
search: filters.search || '',
|
||||
confirmed: filters.confirmed,
|
||||
minScore: filters.minScore,
|
||||
maxScore: filters.maxScore
|
||||
}),
|
||||
[filters.search, filters.confirmed, filters.minScore, filters.maxScore]
|
||||
);
|
||||
|
||||
// 获取数据集列表
|
||||
const fetchDatasets = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let url = `/api/projects/${projectId}/image-datasets?page=${page}&pageSize=${pageSize}`;
|
||||
|
||||
// 搜索条件
|
||||
if (stableFilters.search) {
|
||||
url += `&search=${encodeURIComponent(stableFilters.search)}`;
|
||||
}
|
||||
|
||||
// 确认状态筛选
|
||||
if (stableFilters.confirmed !== undefined) {
|
||||
url += `&confirmed=${stableFilters.confirmed}`;
|
||||
}
|
||||
|
||||
// 评分筛选
|
||||
if (stableFilters.minScore !== undefined || stableFilters.maxScore !== undefined) {
|
||||
if (stableFilters.minScore !== undefined) {
|
||||
url += `&minScore=${stableFilters.minScore}`;
|
||||
}
|
||||
if (stableFilters.maxScore !== undefined) {
|
||||
url += `&maxScore=${stableFilters.maxScore}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get(url);
|
||||
setDatasets(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch datasets:', error);
|
||||
toast.error(t('imageDatasets.fetchFailed', { defaultValue: '获取数据集失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, page, pageSize, stableFilters, t]);
|
||||
|
||||
// 删除数据集
|
||||
const deleteDataset = useCallback(
|
||||
async datasetId => {
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
toast.success(t('imageDatasets.deleteSuccess', { defaultValue: '删除成功' }));
|
||||
fetchDatasets();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dataset:', error);
|
||||
toast.error(t('imageDatasets.deleteFailed', { defaultValue: '删除失败' }));
|
||||
}
|
||||
},
|
||||
[projectId, fetchDatasets, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchDatasets();
|
||||
}
|
||||
}, [projectId, page, stableFilters, fetchDatasets]);
|
||||
|
||||
return {
|
||||
datasets,
|
||||
loading,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
fetchDatasets,
|
||||
deleteDataset
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Container, Box, Typography, Grid, Pagination, CircularProgress, Card, Button } from '@mui/material';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { imageDatasetStyles } from './styles/imageDatasetStyles';
|
||||
import { useImageDatasets } from './hooks/useImageDatasets';
|
||||
import { useImageDatasetFilters } from './hooks/useImageDatasetFilters';
|
||||
import ImageDatasetFilters from './components/ImageDatasetFilters';
|
||||
import ImageDatasetFilterDialog from './components/ImageDatasetFilterDialog';
|
||||
import ImageDatasetCard from './components/ImageDatasetCard';
|
||||
import EmptyState from './components/EmptyState';
|
||||
import ExportImageDatasetDialog from './components/ExportImageDatasetDialog';
|
||||
import useImageDatasetExport from './hooks/useImageDatasetExport';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
export default function ImageDatasetsPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
|
||||
// 使用筛选 Hook
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
scoreFilter,
|
||||
setScoreFilter,
|
||||
getActiveFilterCount,
|
||||
resetFilters
|
||||
} = useImageDatasetFilters(projectId);
|
||||
|
||||
// 使用数据 Hook
|
||||
const { datasets, loading, page, setPage, pageSize, fetchDatasets } = useImageDatasets(projectId, {
|
||||
search: searchQuery,
|
||||
confirmed: statusFilter === 'all' ? undefined : statusFilter === 'confirmed',
|
||||
minScore: scoreFilter[0],
|
||||
maxScore: scoreFilter[1]
|
||||
});
|
||||
|
||||
// 使用导出 Hook
|
||||
const { exportImageDatasets } = useImageDatasetExport(projectId);
|
||||
|
||||
const handlePageChange = (event, value) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCardClick = datasetId => {
|
||||
router.push(`/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
};
|
||||
|
||||
const handleViewDetails = datasetId => {
|
||||
router.push(`/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
};
|
||||
|
||||
const handleDeleteDataset = async datasetId => {
|
||||
if (confirm(t('imageDatasets.deleteConfirm', '确定要删除这个数据集吗?'))) {
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`);
|
||||
toast.success(t('imageDatasets.deleteSuccess', '删除成功'));
|
||||
// 重新查询数据
|
||||
fetchDatasets();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dataset:', error);
|
||||
toast.error(t('imageDatasets.deleteFailed', '删除失败'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvaluateDataset = datasetId => {
|
||||
toast.info(t('common.comingSoon', '功能开发中...'));
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
resetFilters();
|
||||
setFilterDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilterDialogOpen(false);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleExport = async exportOptions => {
|
||||
setExportDialogOpen(false);
|
||||
await exportImageDatasets(exportOptions);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(datasets.total / pageSize);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={imageDatasetStyles.pageContainer}>
|
||||
{/* 筛选区域 - 参考数据集管理的设计 */}
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
py: 2,
|
||||
px: 2,
|
||||
borderRadius: 2,
|
||||
boxShadow: 0,
|
||||
bgcolor: theme => alpha(theme.palette.primary.main, 0.06)
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<ImageDatasetFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={value => {
|
||||
setSearchQuery(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onMoreFiltersClick={() => setFilterDialogOpen(true)}
|
||||
activeFilterCount={getActiveFilterCount()}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileDownloadIcon />}
|
||||
sx={{ borderRadius: 2 }}
|
||||
onClick={() => setExportDialogOpen(true)}
|
||||
>
|
||||
{t('export.title')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* 数据集列表 */}
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : datasets.data.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<Grid container spacing={3}>
|
||||
{datasets.data.map(dataset => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={dataset.id}>
|
||||
<ImageDatasetCard
|
||||
dataset={dataset}
|
||||
onClick={handleCardClick}
|
||||
onView={handleViewDetails}
|
||||
onDelete={handleDeleteDataset}
|
||||
onEvaluate={handleEvaluateDataset}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={imageDatasetStyles.pagination}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
size="large"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 筛选对话框 */}
|
||||
<ImageDatasetFilterDialog
|
||||
open={filterDialogOpen}
|
||||
onClose={() => setFilterDialogOpen(false)}
|
||||
statusFilter={statusFilter}
|
||||
scoreFilter={scoreFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
onScoreChange={setScoreFilter}
|
||||
onResetFilters={handleResetFilters}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
/>
|
||||
|
||||
{/* 导出对话框 */}
|
||||
<ExportImageDatasetDialog
|
||||
open={exportDialogOpen}
|
||||
onClose={() => setExportDialogOpen(false)}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 图片数据集模块样式配置
|
||||
* 参考图片管理模块的精美设计
|
||||
*/
|
||||
|
||||
export const imageDatasetStyles = {
|
||||
// 页面容器
|
||||
pageContainer: {
|
||||
py: 4
|
||||
},
|
||||
|
||||
// 页面头部
|
||||
header: {
|
||||
mb: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
flexWrap: 'wrap',
|
||||
gap: 3
|
||||
},
|
||||
|
||||
headerTitle: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5
|
||||
},
|
||||
|
||||
title: {
|
||||
fontWeight: 700
|
||||
},
|
||||
|
||||
subtitle: {
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.875rem'
|
||||
},
|
||||
|
||||
headerActions: {
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
|
||||
// 筛选区域
|
||||
filterCard: {
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
boxShadow: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'visible'
|
||||
},
|
||||
|
||||
filterContent: {
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
|
||||
// 数据集卡片 - 参考图片管理的设计
|
||||
datasetCard: {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: theme => `0 12px 24px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.15)'}`,
|
||||
borderColor: 'primary.main',
|
||||
'& .image-overlay': {
|
||||
opacity: 1
|
||||
},
|
||||
'& .image-media': {
|
||||
transform: 'scale(1.05)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 图片包装器
|
||||
imageWrapper: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'grey.100'
|
||||
},
|
||||
|
||||
// 图片媒体
|
||||
imageMedia: {
|
||||
className: 'image-media',
|
||||
height: 220,
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.3s ease'
|
||||
},
|
||||
|
||||
// 悬停遮罩
|
||||
imageOverlay: {
|
||||
className: 'image-overlay',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.6) 100%)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
pointerEvents: 'none'
|
||||
},
|
||||
|
||||
// 状态标签容器 - 右上角
|
||||
statusChipsContainer: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
zIndex: 2
|
||||
},
|
||||
|
||||
// 状态标签
|
||||
statusChip: {
|
||||
backdropFilter: 'blur(10px)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
boxShadow: 2
|
||||
},
|
||||
|
||||
// 图片名称容器 - 底部
|
||||
imageNameContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2
|
||||
},
|
||||
|
||||
// 图片名称标签
|
||||
imageNameChip: {
|
||||
backdropFilter: 'blur(10px)',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
||||
fontWeight: 600,
|
||||
maxWidth: '90%',
|
||||
boxShadow: 2,
|
||||
'& .MuiChip-label': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
},
|
||||
|
||||
// 卡片内容
|
||||
cardContent: {
|
||||
flexGrow: 1,
|
||||
p: 2.5
|
||||
},
|
||||
|
||||
// 问题文本
|
||||
questionText: {
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.5,
|
||||
mb: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
|
||||
// 答案预览
|
||||
answerPreview: {
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.6,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
mb: 2
|
||||
},
|
||||
|
||||
// 元数据信息
|
||||
metaInfo: {
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
flexWrap: 'wrap',
|
||||
mt: 2,
|
||||
pt: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider'
|
||||
},
|
||||
|
||||
metaItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary'
|
||||
},
|
||||
|
||||
// 分页样式
|
||||
pagination: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mt: 4
|
||||
},
|
||||
|
||||
// 操作按钮容器
|
||||
actionButtonsContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 0.5,
|
||||
mt: 'auto'
|
||||
},
|
||||
|
||||
// 操作按钮样式
|
||||
actionButton: {
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
color: 'primary.main'
|
||||
}
|
||||
},
|
||||
|
||||
// 空状态
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
py: 12,
|
||||
px: 3
|
||||
},
|
||||
|
||||
emptyIcon: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.lighter',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 3
|
||||
},
|
||||
|
||||
emptyTitle: {
|
||||
fontWeight: 600,
|
||||
mb: 1
|
||||
},
|
||||
|
||||
emptyDescription: {
|
||||
color: 'text.secondary',
|
||||
mb: 4
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Box,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function DatasetDialog({ open, projectId, image, onClose, onSuccess }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuestion('');
|
||||
setError('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedModel) {
|
||||
setError(t('images.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedModel.type !== 'vision') {
|
||||
setError(t('images.visionModelRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!question.trim()) {
|
||||
setError(t('images.questionRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
await axios.post(`/api/projects/${projectId}/images/datasets`, {
|
||||
imageName: image.imageName,
|
||||
question: { question: question.trim() },
|
||||
model: selectedModel,
|
||||
language: i18n.language
|
||||
});
|
||||
|
||||
toast.success(t('images.datasetGenerated'));
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to generate dataset:', err);
|
||||
setError(err.response?.data?.error || t('images.generateFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('images.generateDataset')}</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('images.imageName')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{image.imageName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label={t('images.question')}
|
||||
value={question}
|
||||
onChange={e => setQuestion(e.target.value)}
|
||||
placeholder={t('images.questionPlaceholder')}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{selectedModel && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('images.currentModel')}: {selectedModel.modelName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
variant="contained"
|
||||
disabled={loading || !selectedModel || !question.trim()}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('datasets.generateDataset')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
InputAdornment,
|
||||
Card,
|
||||
CardContent,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import GridViewIcon from '@mui/icons-material/GridView';
|
||||
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { imageStyles } from '../styles/imageStyles';
|
||||
|
||||
export default function ImageFilters({
|
||||
imageName,
|
||||
onImageNameChange,
|
||||
hasQuestions,
|
||||
onHasQuestionsChange,
|
||||
hasDatasets,
|
||||
onHasDatasetsChange,
|
||||
viewMode = 'grid',
|
||||
onViewModeChange
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [localImageName, setLocalImageName] = useState(imageName);
|
||||
const debouncedImageName = useDebounce(localImageName, 500);
|
||||
|
||||
useEffect(() => {
|
||||
onImageNameChange(debouncedImageName);
|
||||
}, [debouncedImageName]);
|
||||
|
||||
return (
|
||||
<Card sx={imageStyles.filterCard}>
|
||||
<CardContent>
|
||||
<Box sx={imageStyles.filterContent}>
|
||||
{/* 搜索框 */}
|
||||
<TextField
|
||||
placeholder={t('images.searchPlaceholder', { defaultValue: '搜索图片名称...' })}
|
||||
value={localImageName}
|
||||
onChange={e => setLocalImageName(e.target.value)}
|
||||
size="small"
|
||||
sx={imageStyles.searchField}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 问题状态筛选 */}
|
||||
<FormControl size="small" sx={imageStyles.filterSelect}>
|
||||
<InputLabel>{t('images.hasQuestions', { defaultValue: '问题状态' })}</InputLabel>
|
||||
<Select
|
||||
value={hasQuestions}
|
||||
onChange={e => onHasQuestionsChange(e.target.value)}
|
||||
label={t('images.hasQuestions', { defaultValue: '问题状态' })}
|
||||
>
|
||||
<MenuItem value="all">{t('common.all', { defaultValue: '全部' })}</MenuItem>
|
||||
<MenuItem value="true">{t('images.withQuestions', { defaultValue: '有问题' })}</MenuItem>
|
||||
<MenuItem value="false">{t('images.withoutQuestions', { defaultValue: '无问题' })}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 数据集状态筛选 */}
|
||||
<FormControl size="small" sx={imageStyles.filterSelect}>
|
||||
<InputLabel>{t('images.hasDatasets', { defaultValue: '数据集状态' })}</InputLabel>
|
||||
<Select
|
||||
value={hasDatasets}
|
||||
onChange={e => onHasDatasetsChange(e.target.value)}
|
||||
label={t('images.hasDatasets', { defaultValue: '数据集状态' })}
|
||||
>
|
||||
<MenuItem value="all">{t('common.all', { defaultValue: '全部' })}</MenuItem>
|
||||
<MenuItem value="true">{t('images.withDatasets', { defaultValue: '已生成' })}</MenuItem>
|
||||
<MenuItem value="false">{t('images.withoutDatasets', { defaultValue: '未生成' })}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 视图切换 */}
|
||||
{onViewModeChange && (
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(e, newMode) => newMode && onViewModeChange(newMode)}
|
||||
size="small"
|
||||
sx={imageStyles.viewToggle}
|
||||
>
|
||||
<ToggleButton value="grid" aria-label="grid view">
|
||||
<GridViewIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="list" aria-label="list view">
|
||||
<ViewListIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Typography,
|
||||
Chip,
|
||||
Box,
|
||||
Pagination,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
||||
import DatasetIcon from '@mui/icons-material/Dataset';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imageStyles } from '../styles/imageStyles';
|
||||
|
||||
export default function ImageGrid({
|
||||
images,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onGenerateQuestions,
|
||||
onGenerateDataset,
|
||||
onDelete,
|
||||
onAnnotate
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [previewImage, setPreviewImage] = useState(null);
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return (
|
||||
<Box sx={imageStyles.emptyState}>
|
||||
<Box sx={imageStyles.emptyIcon}>
|
||||
<PhotoLibraryIcon sx={{ fontSize: 60, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={imageStyles.emptyTitle}>
|
||||
{t('images.noImages', { defaultValue: '还没有图片' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={imageStyles.emptyDescription}>
|
||||
{t('images.noImagesDescription', { defaultValue: '开始导入图片,创建您的第一个图片数据集' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={3}>
|
||||
{images.map(image => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={image.id}>
|
||||
<Card sx={imageStyles.imageCard}>
|
||||
{/* 图片区域 */}
|
||||
<Box sx={imageStyles.imageWrapper}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={image.base64 || image.path}
|
||||
alt={image.imageName}
|
||||
sx={imageStyles.imageMedia}
|
||||
onClick={() => setPreviewImage(image)}
|
||||
/>
|
||||
|
||||
{/* 悬停遮罩 */}
|
||||
<Box sx={imageStyles.imageOverlay} />
|
||||
|
||||
{/* 状态标签 - 悬浮在图片右上角 */}
|
||||
<Box sx={imageStyles.statusChipsContainer}>
|
||||
<Chip
|
||||
label={`${image.questionCount || 0} ${t('images.questions', { defaultValue: '问题' })}`}
|
||||
size="small"
|
||||
color={image.questionCount > 0 ? 'primary' : 'default'}
|
||||
sx={imageStyles.statusChip}
|
||||
/>
|
||||
<Chip
|
||||
label={`${image.datasetCount || 0} ${t('images.datasets', { defaultValue: '数据集' })}`}
|
||||
size="small"
|
||||
color={image.datasetCount > 0 ? 'success' : 'default'}
|
||||
sx={imageStyles.statusChip}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 文件名标签 - 悬浮在图片底部 */}
|
||||
<Box sx={imageStyles.imageNameContainer}>
|
||||
<Tooltip title={image.imageName}>
|
||||
<Chip label={image.imageName} size="small" sx={imageStyles.imageNameChip} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<CardActions sx={imageStyles.cardActions}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<EditNoteIcon />}
|
||||
onClick={() => onAnnotate(image)}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={imageStyles.primaryActionButton}
|
||||
>
|
||||
{t('images.annotate', { defaultValue: '标注' })}
|
||||
</Button>
|
||||
<Tooltip title={t('images.generateQuestions', { defaultValue: '生成问题' })}>
|
||||
<IconButton size="small" onClick={() => onGenerateQuestions(image)} sx={imageStyles.actionIconButton}>
|
||||
<QuestionMarkIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('images.generateDataset', { defaultValue: '生成数据集' })}>
|
||||
<IconButton size="small" onClick={() => onGenerateDataset(image)} sx={imageStyles.actionIconButton}>
|
||||
<DatasetIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete', { defaultValue: '删除' })}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => onDelete(image.id)}
|
||||
sx={imageStyles.actionIconButton}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{total > pageSize && (
|
||||
<Box sx={imageStyles.pagination}>
|
||||
<Pagination
|
||||
count={Math.ceil(total / pageSize)}
|
||||
page={page}
|
||||
onChange={(_, newPage) => onPageChange(newPage)}
|
||||
color="primary"
|
||||
size="large"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 图片预览对话框 */}
|
||||
<Dialog
|
||||
open={!!previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
sx={{ p: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{previewImage && (
|
||||
<Box sx={{ width: '100%', textAlign: 'center' }}>
|
||||
<img
|
||||
src={previewImage.base64 || previewImage.path}
|
||||
alt={previewImage.imageName}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '90vh',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', mt: 2, color: 'white', textShadow: '0 0 4px rgba(0,0,0,0.8)' }}
|
||||
>
|
||||
{previewImage.imageName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
Box,
|
||||
Pagination,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Avatar,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
Typography,
|
||||
Button,
|
||||
Checkbox
|
||||
} from '@mui/material';
|
||||
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
||||
import DatasetIcon from '@mui/icons-material/Dataset';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imageStyles } from '../styles/imageStyles';
|
||||
|
||||
export default function ImageList({
|
||||
images,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onGenerateQuestions,
|
||||
onGenerateDataset,
|
||||
onDelete,
|
||||
onAnnotate,
|
||||
selectedIds = [],
|
||||
onSelectionChange
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [previewImage, setPreviewImage] = useState(null);
|
||||
|
||||
// 处理全选/取消全选
|
||||
const handleSelectAll = event => {
|
||||
if (event.target.checked) {
|
||||
const allIds = images.map(img => img.id);
|
||||
onSelectionChange?.(allIds);
|
||||
} else {
|
||||
onSelectionChange?.([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单个选择
|
||||
const handleSelectOne = (imageId, checked) => {
|
||||
if (checked) {
|
||||
onSelectionChange?.([...selectedIds, imageId]);
|
||||
} else {
|
||||
onSelectionChange?.(selectedIds.filter(id => id !== imageId));
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否全选
|
||||
const isAllSelected = images.length > 0 && selectedIds.length === images.length;
|
||||
const isSomeSelected = selectedIds.length > 0 && selectedIds.length < images.length;
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = dateString => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
const formatSize = bytes => {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return (
|
||||
<Box sx={imageStyles.emptyState}>
|
||||
<Box sx={imageStyles.emptyIcon}>
|
||||
<PhotoLibraryIcon sx={{ fontSize: 60, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={imageStyles.emptyTitle}>
|
||||
{t('images.noImages', { defaultValue: '还没有图片' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={imageStyles.emptyDescription}>
|
||||
{t('images.noImagesDescription', { defaultValue: '开始导入图片,创建您的第一个图片数据集' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer component={Paper} sx={{ borderRadius: 2, boxShadow: 1 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox indeterminate={isSomeSelected} checked={isAllSelected} onChange={handleSelectAll} />
|
||||
</TableCell>
|
||||
<TableCell width="60">{t('images.preview', { defaultValue: '预览' })}</TableCell>
|
||||
<TableCell>{t('images.fileName', { defaultValue: '文件名' })}</TableCell>
|
||||
<TableCell width="120">{t('images.size', { defaultValue: '大小' })}</TableCell>
|
||||
<TableCell width="120">{t('images.dimensions', { defaultValue: '尺寸' })}</TableCell>
|
||||
<TableCell width="100">{t('images.questionCount', { defaultValue: '问题数' })}</TableCell>
|
||||
<TableCell width="100">{t('images.datasetCount', { defaultValue: '数据集数' })}</TableCell>
|
||||
<TableCell width="180">{t('images.uploadTime', { defaultValue: '上传时间' })}</TableCell>
|
||||
<TableCell width="200" align="center">
|
||||
{t('common.actions', { defaultValue: '操作' })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images.map(image => (
|
||||
<TableRow
|
||||
key={image.id}
|
||||
hover
|
||||
selected={selectedIds.includes(image.id)}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 复选框 */}
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(image.id)}
|
||||
onChange={e => handleSelectOne(image.id, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 预览缩略图 */}
|
||||
<TableCell>
|
||||
<Avatar
|
||||
src={image.base64 || image.path}
|
||||
alt={image.imageName}
|
||||
variant="rounded"
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
opacity: 0.8
|
||||
}
|
||||
}}
|
||||
onClick={() => setPreviewImage(image)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 文件名 */}
|
||||
<TableCell>
|
||||
<Tooltip title={image.imageName}>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: 300 }}>
|
||||
{image.imageName}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
{/* 文件大小 */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatSize(image.size)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<TableCell>
|
||||
{image.width && image.height ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{image.width} × {image.height}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 问题数 */}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={image.questionCount || 0}
|
||||
size="small"
|
||||
color={image.questionCount > 0 ? 'primary' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 数据集数 */}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={image.datasetCount || 0}
|
||||
size="small"
|
||||
color={image.datasetCount > 0 ? 'success' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 上传时间 */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDate(image.createAt)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, justifyContent: 'center' }}>
|
||||
<Tooltip title={t('images.preview', { defaultValue: '预览' })}>
|
||||
<IconButton size="small" onClick={() => setPreviewImage(image)}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('images.annotate', { defaultValue: '标注' })}>
|
||||
<IconButton size="small" color="primary" onClick={() => onAnnotate(image)}>
|
||||
<EditNoteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('images.generateQuestions', { defaultValue: '生成问题' })}>
|
||||
<IconButton size="small" onClick={() => onGenerateQuestions(image)}>
|
||||
<QuestionMarkIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('images.generateDataset', { defaultValue: '生成数据集' })}>
|
||||
<IconButton size="small" onClick={() => onGenerateDataset(image)}>
|
||||
<DatasetIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete', { defaultValue: '删除' })}>
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(image.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* 分页 */}
|
||||
{total > pageSize && (
|
||||
<Box sx={imageStyles.pagination}>
|
||||
<Pagination
|
||||
count={Math.ceil(total / pageSize)}
|
||||
page={page}
|
||||
onChange={(_, newPage) => onPageChange(newPage)}
|
||||
color="primary"
|
||||
size="large"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 图片预览对话框 */}
|
||||
<Dialog
|
||||
open={!!previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
sx={{ p: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{previewImage && (
|
||||
<Box sx={{ width: '100%', textAlign: 'center' }}>
|
||||
<img
|
||||
src={previewImage.base64 || previewImage.path}
|
||||
alt={previewImage.imageName}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '90vh',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', mt: 2, color: 'white', textShadow: '0 0 4px rgba(0,0,0,0.8)' }}
|
||||
>
|
||||
{previewImage.imageName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
TextField,
|
||||
Tabs,
|
||||
Tab,
|
||||
Paper,
|
||||
Chip,
|
||||
Card
|
||||
} from '@mui/material';
|
||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import FolderZipIcon from '@mui/icons-material/FolderZip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function ImportDialog({ open, projectId, onClose, onSuccess }) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState(0); // 0: 目录导入, 1: PDF 导入, 2: 压缩包导入
|
||||
const [directories, setDirectories] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputPath, setInputPath] = useState('');
|
||||
const [selectedPdf, setSelectedPdf] = useState(null);
|
||||
const [selectedZip, setSelectedZip] = useState(null);
|
||||
|
||||
const handleAddDirectory = () => {
|
||||
if (inputPath.trim() && !directories.includes(inputPath.trim())) {
|
||||
setDirectories([...directories, inputPath.trim()]);
|
||||
setInputPath('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDirectory = index => {
|
||||
setDirectories(directories.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (directories.length === 0) {
|
||||
toast.error(t('images.selectAtLeastOne'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.post(`/api/projects/${projectId}/images`, {
|
||||
directories
|
||||
});
|
||||
|
||||
toast.success(t('images.importSuccess', { count: response.data.count }));
|
||||
setDirectories([]);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to import images:', error);
|
||||
toast.error(error.response?.data?.error || t('images.importFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdfSelect = event => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && file.type === 'application/pdf') {
|
||||
setSelectedPdf(file);
|
||||
} else {
|
||||
toast.error(t('images.invalidPdfFile', { defaultValue: '请选择有效的 PDF 文件' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdfImport = async () => {
|
||||
if (!selectedPdf) {
|
||||
toast.error(t('images.selectPdfFile', { defaultValue: '请选择 PDF 文件' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedPdf);
|
||||
|
||||
// 调用 PDF 转换 API
|
||||
const response = await axios.post(`/api/projects/${projectId}/images/pdf-convert`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t('images.pdfImportSuccess', {
|
||||
defaultValue: `成功从 PDF "${response.data.pdfName}" 导入 ${response.data.count} 张图片`,
|
||||
count: response.data.count,
|
||||
name: response.data.pdfName
|
||||
})
|
||||
);
|
||||
setSelectedPdf(null);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to import PDF:', error);
|
||||
toast.error(error.response?.data?.error || t('images.pdfImportFailed', { defaultValue: 'PDF 导入失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZipSelect = event => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && file.name.toLowerCase().endsWith('.zip')) {
|
||||
setSelectedZip(file);
|
||||
} else {
|
||||
toast.error(t('images.invalidZipFile', { defaultValue: '请选择有效的 ZIP 文件' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleZipImport = async () => {
|
||||
if (!selectedZip) {
|
||||
toast.error(t('images.selectZipFile', { defaultValue: '请选择 ZIP 文件' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedZip);
|
||||
|
||||
// 调用压缩包导入 API
|
||||
const response = await axios.post(`/api/projects/${projectId}/images/zip-import`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t('images.zipImportSuccess', {
|
||||
defaultValue: `成功从压缩包 "${response.data.zipName}" 导入 ${response.data.count} 张图片`,
|
||||
count: response.data.count,
|
||||
name: response.data.zipName
|
||||
})
|
||||
);
|
||||
setSelectedZip(null);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to import ZIP:', error);
|
||||
toast.error(error.response?.data?.error || t('images.zipImportFailed', { defaultValue: '压缩包导入失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
setDirectories([]);
|
||||
setSelectedPdf(null);
|
||||
setSelectedZip(null);
|
||||
setMode(0);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('images.importImages')}</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
<Tabs
|
||||
value={mode}
|
||||
onChange={(e, newValue) => setMode(newValue)}
|
||||
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab
|
||||
label={t('images.importFromDirectory', { defaultValue: '从目录导入' })}
|
||||
icon={<FolderOpenIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label={t('images.importFromPdf', { defaultValue: '从 PDF 导入' })}
|
||||
icon={<PictureAsPdfIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label={t('images.importFromZip', { defaultValue: '从压缩包导入' })}
|
||||
icon={<FolderZipIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{mode === 0 ? (
|
||||
<>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('images.importTip')}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('images.directoryPath')}
|
||||
placeholder={t('images.enterDirectoryPath')}
|
||||
value={inputPath}
|
||||
onChange={e => setInputPath(e.target.value)}
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddDirectory();
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<FolderOpenIcon />}
|
||||
onClick={handleAddDirectory}
|
||||
disabled={loading || !inputPath.trim()}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
px: 2.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: 1,
|
||||
transition: 'all 0.2s',
|
||||
'&:hover:not(:disabled)': {
|
||||
boxShadow: 2,
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('images.addDirectory', { defaultValue: '添加目录' })}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{directories.length > 0 && (
|
||||
<Card
|
||||
sx={{
|
||||
p: 2.5,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<FolderOpenIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{t('images.selectedDirectories')} ({directories.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{directories.map((dir, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={dir}
|
||||
onDelete={() => handleRemoveDirectory(index)}
|
||||
disabled={loading}
|
||||
icon={<FolderOpenIcon />}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
fontWeight: 500,
|
||||
maxWidth: '100%',
|
||||
'& .MuiChip-label': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : mode === 1 ? (
|
||||
<>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('images.pdfImportTip', { defaultValue: '选择 PDF 文件,系统会自动将其转换为图片并导入' })}
|
||||
</Alert>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'background.default',
|
||||
border: '2px dashed',
|
||||
borderColor: selectedPdf ? 'primary.main' : 'divider',
|
||||
transition: 'all 0.3s',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => document.getElementById('pdf-file-input').click()}
|
||||
>
|
||||
<input
|
||||
id="pdf-file-input"
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handlePdfSelect}
|
||||
disabled={loading}
|
||||
/>
|
||||
<UploadFileIcon sx={{ fontSize: 64, color: selectedPdf ? 'primary.main' : 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{selectedPdf ? selectedPdf.name : t('images.clickToSelectPdf', { defaultValue: '点击选择 PDF 文件' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('images.supportedFormat', { defaultValue: '支持格式:PDF' })}
|
||||
</Typography>
|
||||
{selectedPdf && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
{t('images.fileSize', { defaultValue: '文件大小' })}: {(selectedPdf.size / 1024 / 1024).toFixed(2)} MB
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('images.zipImportTip', { defaultValue: '选择 ZIP 压缩包文件,系统会自动解压并导入其中的图片' })}
|
||||
</Alert>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'background.default',
|
||||
border: '2px dashed',
|
||||
borderColor: selectedZip ? 'primary.main' : 'divider',
|
||||
transition: 'all 0.3s',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => document.getElementById('zip-file-input').click()}
|
||||
>
|
||||
<input
|
||||
id="zip-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleZipSelect}
|
||||
disabled={loading}
|
||||
/>
|
||||
<FolderZipIcon sx={{ fontSize: 64, color: selectedZip ? 'primary.main' : 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{selectedZip ? selectedZip.name : t('images.clickToSelectZip', { defaultValue: '点击选择 ZIP 文件' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('images.supportedZipFormat', { defaultValue: '支持格式:ZIP' })}
|
||||
</Typography>
|
||||
{selectedZip && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
{t('images.fileSize', { defaultValue: '文件大小' })}: {(selectedZip.size / 1024 / 1024).toFixed(2)} MB
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{mode === 0 ? (
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
variant="contained"
|
||||
disabled={loading || directories.length === 0}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('images.startImport')}
|
||||
</Button>
|
||||
) : mode === 1 ? (
|
||||
<Button
|
||||
onClick={handlePdfImport}
|
||||
variant="contained"
|
||||
disabled={loading || !selectedPdf}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('images.convertAndImport', { defaultValue: '转换并导入' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleZipImport}
|
||||
variant="contained"
|
||||
disabled={loading || !selectedZip}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('images.extractAndImport', { defaultValue: '解压并导入' })}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Box,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function QuestionDialog({ open, projectId, image, onClose, onSuccess }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
const [count, setCount] = useState(3);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCount(3);
|
||||
setError('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedModel) {
|
||||
setError(t('images.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedModel.type !== 'vision') {
|
||||
setError(t('images.visionModelRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (count < 1 || count > 10) {
|
||||
setError(t('images.countRange'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await axios.post(`/api/projects/${projectId}/images/questions`, {
|
||||
imageName: image.imageName,
|
||||
count,
|
||||
model: selectedModel,
|
||||
language: i18n.language
|
||||
});
|
||||
|
||||
toast.success(t('images.questionsGenerated', { count: response.data.questions.length }));
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to generate questions:', err);
|
||||
setError(err.response?.data?.error || t('images.generateFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('images.generateQuestions')}</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('images.imageName')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{image.imageName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={t('images.questionCount')}
|
||||
value={count}
|
||||
onChange={e => setCount(parseInt(e.target.value) || 1)}
|
||||
inputProps={{ min: 1, max: 10 }}
|
||||
helperText={t('images.questionCountHelp')}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{selectedModel && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('images.currentModel')}: {selectedModel.modelName}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
variant="contained"
|
||||
disabled={loading || !selectedModel}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('images.generateQuestions')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 深度遍历 JSON,将所有值设为空字符串
|
||||
function clearJsonValues(obj) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => clearJsonValues(item));
|
||||
} else if (obj !== null && typeof obj === 'object') {
|
||||
const cleared = {};
|
||||
for (const key in obj) {
|
||||
cleared[key] = clearJsonValues(obj[key]);
|
||||
}
|
||||
return cleared;
|
||||
} else {
|
||||
return ''; // 所有基础类型值都变为空字符串
|
||||
}
|
||||
}
|
||||
|
||||
export function useAnnotation(projectId, onSuccess, onFindNextImage) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentImage, setCurrentImage] = useState(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [answer, setAnswer] = useState('');
|
||||
|
||||
// 打开标注对话框
|
||||
const openAnnotation = async (image, template = null) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取图片详情,包括已标注和未标注的问题
|
||||
const response = await axios.get(`/api/projects/${projectId}/images/${image.id}`);
|
||||
if (response.data.success) {
|
||||
const imageDetail = response.data.data;
|
||||
setCurrentImage(imageDetail);
|
||||
|
||||
// 如果没有指定模板,尝试选择第一个未标注的问题
|
||||
if (!template) {
|
||||
if (imageDetail.unansweredQuestions?.length > 0) {
|
||||
template = imageDetail.unansweredQuestions[0];
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedTemplate(template);
|
||||
|
||||
// 根据问题类型初始化答案
|
||||
let initialAnswer = '';
|
||||
if (template?.answerType === 'label') {
|
||||
initialAnswer = [];
|
||||
} else if (template?.answerType === 'custom_format' && template?.customFormat) {
|
||||
// 为自定义格式提供默认值(所有字段值清空)
|
||||
try {
|
||||
let templateJson;
|
||||
if (typeof template.customFormat === 'string') {
|
||||
// 如果customFormat是字符串,尝试解析为JSON
|
||||
templateJson = JSON.parse(template.customFormat);
|
||||
} else {
|
||||
// 如果customFormat已经是对象,直接使用
|
||||
templateJson = template.customFormat;
|
||||
}
|
||||
// 深度遍历,将所有字段值清空
|
||||
const clearedJson = clearJsonValues(templateJson);
|
||||
initialAnswer = JSON.stringify(clearedJson, null, 2);
|
||||
} catch (error) {
|
||||
// 如枟解析失败,提供一个空的JSON对象
|
||||
initialAnswer = '{}';
|
||||
}
|
||||
}
|
||||
|
||||
setAnswer(initialAnswer);
|
||||
setOpen(true);
|
||||
} else {
|
||||
toast.error(t('images.loadImageDetailFailed', { defaultValue: '加载图片详情失败' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取图片详情失败:', error);
|
||||
toast.error(t('images.loadImageDetailFailed', { defaultValue: '加载图片详情失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const closeAnnotation = () => {
|
||||
setOpen(false);
|
||||
setCurrentImage(null);
|
||||
setSelectedTemplate(null);
|
||||
setAnswer('');
|
||||
};
|
||||
|
||||
// 刷新当前图片的问题列表(创建问题后调用)
|
||||
const refreshCurrentImage = async () => {
|
||||
if (!currentImage) return;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/projects/${projectId}/images/${currentImage.id}`);
|
||||
if (response.data.success) {
|
||||
const imageDetail = response.data.data;
|
||||
// 更新当前图片数据
|
||||
setCurrentImage(imageDetail);
|
||||
return imageDetail;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新图片详情失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 查找下一个未标注的问题
|
||||
const findNextUnansweredQuestion = async () => {
|
||||
// 重新获取图片详情,获取最新的问题列表
|
||||
try {
|
||||
const response = await axios.get(`/api/projects/${projectId}/images/${currentImage.id}`);
|
||||
if (response.data.success) {
|
||||
const imageDetail = response.data.data;
|
||||
|
||||
// 更新当前图片数据
|
||||
setCurrentImage(imageDetail);
|
||||
|
||||
// 返回第一个未标注的问题
|
||||
if (imageDetail.unansweredQuestions?.length > 0) {
|
||||
return imageDetail.unansweredQuestions[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取下一个问题失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存标注
|
||||
const saveAnnotation = async (continueNext = false) => {
|
||||
if (!currentImage) {
|
||||
toast.error(t('images.noImageSelected', { defaultValue: '未选择图片' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTemplate) {
|
||||
toast.error(t('images.noTemplateSelected', { defaultValue: '请选择问题' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
if (!answer || (Array.isArray(answer) && answer.length === 0)) {
|
||||
toast.error(t('images.answerRequired', { defaultValue: '请输入答案' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是自定义格式,验证 JSON 格式
|
||||
if (selectedTemplate.answerType === 'custom_format') {
|
||||
try {
|
||||
JSON.parse(answer);
|
||||
} catch (e) {
|
||||
toast.error(t('images.invalidJsonFormat', { defaultValue: 'JSON 格式不正确' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(999, answer);
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await axios.post(`/api/projects/${projectId}/images/annotations`, {
|
||||
imageId: currentImage.id,
|
||||
imageName: currentImage.imageName,
|
||||
questionId: selectedTemplate.id,
|
||||
question: selectedTemplate.question,
|
||||
templateId: selectedTemplate.id,
|
||||
answerType: selectedTemplate.answerType,
|
||||
answer
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success(t('images.annotationSuccess', { defaultValue: '标注保存成功' }));
|
||||
|
||||
// 触发刷新回调
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
if (continueNext) {
|
||||
// 查找下一个未标注的问题
|
||||
const nextQuestion = await findNextUnansweredQuestion();
|
||||
|
||||
if (nextQuestion) {
|
||||
// 切换到下一个问题
|
||||
setSelectedTemplate(nextQuestion);
|
||||
|
||||
// 根据问题类型初始化答案
|
||||
let initialAnswer = '';
|
||||
if (nextQuestion.answerType === 'label') {
|
||||
initialAnswer = [];
|
||||
} else if (nextQuestion.answerType === 'custom_format' && nextQuestion.customFormat) {
|
||||
try {
|
||||
let templateJson;
|
||||
if (typeof nextQuestion.customFormat === 'string') {
|
||||
templateJson = JSON.parse(nextQuestion.customFormat);
|
||||
} else {
|
||||
templateJson = nextQuestion.customFormat;
|
||||
}
|
||||
const clearedJson = clearJsonValues(templateJson);
|
||||
initialAnswer = JSON.stringify(clearedJson, null, 2);
|
||||
} catch (error) {
|
||||
initialAnswer = '{}';
|
||||
}
|
||||
}
|
||||
setAnswer(initialAnswer);
|
||||
} else {
|
||||
// 没有更多未标注的问题了,尝试查找下一个有未标注问题的图片
|
||||
if (onFindNextImage) {
|
||||
const nextImage = await onFindNextImage();
|
||||
if (nextImage) {
|
||||
// 打开下一个图片的标注
|
||||
await openAnnotation(nextImage);
|
||||
} else {
|
||||
// 没有更多图片了
|
||||
toast.info(t('images.allImagesAnnotated', { defaultValue: '所有图片的问题都已标注完成' }));
|
||||
closeAnnotation();
|
||||
}
|
||||
} else {
|
||||
toast.info(t('images.allQuestionsAnnotated', { defaultValue: '当前图片所有问题已标注完成' }));
|
||||
closeAnnotation();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
closeAnnotation();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存标注失败:', error);
|
||||
const errorMsg = error.response?.data?.error || t('images.annotationFailed', { defaultValue: '保存标注失败' });
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理模板变更
|
||||
const handleTemplateChange = template => {
|
||||
setSelectedTemplate(template);
|
||||
|
||||
// 根据新模板类型初始化答案
|
||||
let initialAnswer = '';
|
||||
if (template?.answerType === 'label') {
|
||||
initialAnswer = [];
|
||||
} else if (template?.answerType === 'custom_format' && template?.customFormat) {
|
||||
// 为自定义格式提供默认值(所有字段值清空)
|
||||
try {
|
||||
let templateJson;
|
||||
if (typeof template.customFormat === 'string') {
|
||||
// 如果customFormat是字符串,尝试解析为JSON
|
||||
templateJson = JSON.parse(template.customFormat);
|
||||
} else {
|
||||
// 如果customFormat已经是对象,直接使用
|
||||
templateJson = template.customFormat;
|
||||
}
|
||||
// 深度遍历,将所有字段值清空
|
||||
const clearedJson = clearJsonValues(templateJson);
|
||||
initialAnswer = JSON.stringify(clearedJson, null, 2);
|
||||
} catch (error) {
|
||||
// 如枟解析失败,提供一个空的JSON对象
|
||||
initialAnswer = '{}';
|
||||
}
|
||||
}
|
||||
|
||||
setAnswer(initialAnswer);
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
saving,
|
||||
loading,
|
||||
currentImage,
|
||||
selectedTemplate,
|
||||
answer,
|
||||
setSelectedTemplate,
|
||||
setAnswer,
|
||||
handleTemplateChange,
|
||||
openAnnotation,
|
||||
closeAnnotation,
|
||||
saveAnnotation,
|
||||
refreshCurrentImage
|
||||
};
|
||||
}
|
||||
486
easy-dataset-main/app/projects/[projectId]/images/page.js
Normal file
486
easy-dataset-main/app/projects/[projectId]/images/page.js
Normal file
@@ -0,0 +1,486 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { imageStyles } from './styles/imageStyles';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
import ImageFilters from './components/ImageFilters';
|
||||
import ImageGrid from './components/ImageGrid';
|
||||
import ImageList from './components/ImageList';
|
||||
import ImportDialog from './components/ImportDialog';
|
||||
import QuestionDialog from './components/QuestionDialog';
|
||||
import DatasetDialog from './components/DatasetDialog';
|
||||
import AnnotationDialog from './components/annotation/AnnotationDialog';
|
||||
import { useQuestionTemplates } from '../questions/hooks/useQuestionTemplates';
|
||||
import { useAnnotation } from './hooks/useAnnotation';
|
||||
import { useQuestionEdit } from '../questions/hooks/useQuestionEdit';
|
||||
import QuestionEditDialog from '../questions/components/QuestionEditDialog';
|
||||
import TemplateFormDialog from '../questions/components/template/TemplateFormDialog';
|
||||
|
||||
export default function ImagesPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [images, setImages] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(8);
|
||||
|
||||
// 筛选条件
|
||||
const [imageName, setImageName] = useState('');
|
||||
const [hasQuestions, setHasQuestions] = useState('all');
|
||||
const [hasDatasets, setHasDatasets] = useState('all');
|
||||
|
||||
// 视图模式
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
|
||||
// 选中状态(仅列表视图使用)
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
|
||||
// 对话框状态
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [questionDialogOpen, setQuestionDialogOpen] = useState(false);
|
||||
const [datasetDialogOpen, setDatasetDialogOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [autoGenerateDialogOpen, setAutoGenerateDialogOpen] = useState(false);
|
||||
const [questionCount, setQuestionCount] = useState(3);
|
||||
|
||||
// 问题模板和标注功能 (只获取图像类型的模板)
|
||||
const { templates, createTemplate } = useQuestionTemplates(projectId, 'image');
|
||||
|
||||
// 问题编辑 Hook
|
||||
const { editDialogOpen, editMode, editingQuestion, handleOpenCreateDialog, handleCloseDialog, handleSubmitQuestion } =
|
||||
useQuestionEdit(projectId, async () => {
|
||||
fetchImages();
|
||||
if (annotationOpen && currentImage) {
|
||||
await refreshCurrentImage();
|
||||
}
|
||||
toast.success(t('questions.operationSuccess'));
|
||||
});
|
||||
|
||||
// 模板管理状态
|
||||
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
|
||||
|
||||
// 获取图片列表
|
||||
const fetchImages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
if (imageName) params.append('imageName', imageName);
|
||||
if (hasQuestions !== 'all') params.append('hasQuestions', hasQuestions);
|
||||
if (hasDatasets !== 'all') params.append('hasDatasets', hasDatasets);
|
||||
|
||||
const response = await axios.get(`/api/projects/${projectId}/images?${params.toString()}`);
|
||||
setImages(response.data.data);
|
||||
setTotal(response.data.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch images:', error);
|
||||
toast.error(t('common.fetchError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 查找下一个有未标注问题的图片
|
||||
const handleFindNextImage = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/projects/${projectId}/images/next-unanswered`);
|
||||
return response.data.data || null;
|
||||
} catch (error) {
|
||||
console.error('查找下一个图片失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
open: annotationOpen,
|
||||
saving: annotationSaving,
|
||||
loading: annotationLoading,
|
||||
currentImage,
|
||||
selectedTemplate,
|
||||
answer,
|
||||
setAnswer,
|
||||
handleTemplateChange,
|
||||
openAnnotation,
|
||||
closeAnnotation,
|
||||
saveAnnotation,
|
||||
refreshCurrentImage
|
||||
} = useAnnotation(projectId, fetchImages, handleFindNextImage);
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages();
|
||||
}, [projectId, page, imageName, hasQuestions, hasDatasets]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
}, [viewMode]);
|
||||
|
||||
// 处理导入成功
|
||||
const handleImportSuccess = () => {
|
||||
setImportDialogOpen(false);
|
||||
setPage(1);
|
||||
fetchImages();
|
||||
};
|
||||
|
||||
// 处理生成问题
|
||||
const handleGenerateQuestions = image => {
|
||||
setSelectedImage(image);
|
||||
setQuestionDialogOpen(true);
|
||||
};
|
||||
|
||||
// 处理生成数据集
|
||||
const handleGenerateDataset = image => {
|
||||
setSelectedImage(image);
|
||||
setDatasetDialogOpen(true);
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleDeleteImage = async imageId => {
|
||||
if (!confirm(t('images.deleteConfirm', { defaultValue: '确定要删除这张图片吗?' }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/images?imageId=${imageId}`);
|
||||
toast.success(t('images.deleteSuccess', { defaultValue: '删除成功' }));
|
||||
fetchImages();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete image:', error);
|
||||
toast.error(t('images.deleteFailed', { defaultValue: '删除失败' }));
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除图片
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error(t('images.selectImagesToDelete', { defaultValue: '请选择要删除的图片' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
t('images.batchDeleteConfirm', {
|
||||
defaultValue: `确定要删除选中的 ${selectedIds.length} 张图片吗?`,
|
||||
count: selectedIds.length
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// 逐个调用删除接口
|
||||
for (const imageId of selectedIds) {
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/images?imageId=${imageId}`);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete image ${imageId}:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
if (failCount === 0) {
|
||||
toast.success(
|
||||
t('images.batchDeleteSuccess', {
|
||||
defaultValue: `成功删除 ${successCount} 张图片`,
|
||||
count: successCount
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.warning(
|
||||
t('images.batchDeletePartialSuccess', {
|
||||
defaultValue: `成功删除 ${successCount} 张,失败 ${failCount} 张`,
|
||||
success: successCount,
|
||||
fail: failCount
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 清空选中状态并刷新列表
|
||||
setSelectedIds([]);
|
||||
fetchImages();
|
||||
} catch (error) {
|
||||
console.error('Batch delete failed:', error);
|
||||
toast.error(t('images.batchDeleteFailed', { defaultValue: '批量删除失败' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理自动提取问题
|
||||
const handleAutoGenerateQuestions = () => {
|
||||
if (!selectedModel) {
|
||||
toast.error(t('images.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedModel.type !== 'vision') {
|
||||
toast.error(t('images.visionModelRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoGenerateDialogOpen(true);
|
||||
};
|
||||
|
||||
// 确认创建自动提取任务
|
||||
const handleConfirmAutoGenerate = async () => {
|
||||
// 验证问题数量
|
||||
if (questionCount < 1 || questionCount > 10) {
|
||||
toast.error(t('images.countRange'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAutoGenerateDialogOpen(false);
|
||||
|
||||
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
|
||||
taskType: 'image-question-generation',
|
||||
modelInfo: selectedModel,
|
||||
language: i18n.language,
|
||||
note: { questionCount }
|
||||
});
|
||||
|
||||
if (response.data.code === 0) {
|
||||
toast.success(t('images.taskCreated'));
|
||||
// 跳转到任务管理页面
|
||||
router.push(`/projects/${projectId}/tasks`);
|
||||
} else {
|
||||
toast.error(response.data.error || t('images.taskCreateFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create auto-generate task:', error);
|
||||
toast.error(t('images.taskCreateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 模板管理函数
|
||||
const handleOpenCreateTemplateDialog = () => {
|
||||
setTemplateDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseTemplateDialog = () => {
|
||||
setTemplateDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmitTemplate = async data => {
|
||||
try {
|
||||
await createTemplate(data);
|
||||
handleCloseTemplateDialog();
|
||||
fetchImages();
|
||||
if (annotationOpen && currentImage) {
|
||||
await refreshCurrentImage();
|
||||
}
|
||||
toast.success(t('questions.operationSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={imageStyles.pageContainer}>
|
||||
{/* 页面头部 */}
|
||||
<Box sx={imageStyles.header}>
|
||||
<Box sx={imageStyles.headerTitle}>
|
||||
<Typography variant="h4" component="h1" sx={imageStyles.title}>
|
||||
{t('images.title', { defaultValue: '图片管理' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={imageStyles.headerActions}>
|
||||
{viewMode === 'list' && selectedIds.length > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleBatchDelete}
|
||||
sx={imageStyles.actionButton}
|
||||
>
|
||||
{t('images.batchDelete', { defaultValue: '批量删除' })} ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={handleAutoGenerateQuestions}
|
||||
sx={imageStyles.actionButton}
|
||||
>
|
||||
{t('images.autoGenerateQuestions', { defaultValue: 'AI 批量生成问题' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddPhotoAlternateIcon />}
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
sx={imageStyles.actionButton}
|
||||
>
|
||||
{t('images.importImages', { defaultValue: '导入图片' })}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 筛选区域 */}
|
||||
<ImageFilters
|
||||
imageName={imageName}
|
||||
onImageNameChange={setImageName}
|
||||
hasQuestions={hasQuestions}
|
||||
onHasQuestionsChange={setHasQuestions}
|
||||
hasDatasets={hasDatasets}
|
||||
onHasDatasetsChange={setHasDatasets}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* 图片列表 */}
|
||||
{loading ? (
|
||||
<Box sx={imageStyles.loadingContainer}>
|
||||
<CircularProgress size={48} />
|
||||
</Box>
|
||||
) : viewMode === 'grid' ? (
|
||||
<ImageGrid
|
||||
images={images}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onGenerateQuestions={handleGenerateQuestions}
|
||||
onGenerateDataset={handleGenerateDataset}
|
||||
onDelete={handleDeleteImage}
|
||||
onAnnotate={openAnnotation}
|
||||
/>
|
||||
) : (
|
||||
<ImageList
|
||||
images={images}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onGenerateQuestions={handleGenerateQuestions}
|
||||
onGenerateDataset={handleGenerateDataset}
|
||||
onDelete={handleDeleteImage}
|
||||
onAnnotate={openAnnotation}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ImportDialog
|
||||
open={importDialogOpen}
|
||||
projectId={projectId}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
onSuccess={handleImportSuccess}
|
||||
/>
|
||||
|
||||
<QuestionDialog
|
||||
open={questionDialogOpen}
|
||||
projectId={projectId}
|
||||
image={selectedImage}
|
||||
onClose={() => setQuestionDialogOpen(false)}
|
||||
onSuccess={fetchImages}
|
||||
/>
|
||||
|
||||
<DatasetDialog
|
||||
open={datasetDialogOpen}
|
||||
projectId={projectId}
|
||||
image={selectedImage}
|
||||
onClose={() => setDatasetDialogOpen(false)}
|
||||
onSuccess={fetchImages}
|
||||
/>
|
||||
|
||||
<Dialog open={autoGenerateDialogOpen} onClose={() => setAutoGenerateDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('images.autoGenerateQuestions')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ mb: 3 }}>{t('images.autoGenerateConfirm')}</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={t('images.questionCount')}
|
||||
value={questionCount}
|
||||
onChange={e => setQuestionCount(parseInt(e.target.value) || 1)}
|
||||
inputProps={{ min: 1, max: 10 }}
|
||||
helperText={t('images.questionCountHelp')}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('images.currentModel')}: {selectedModel?.modelName || t('common.none')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAutoGenerateDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleConfirmAutoGenerate} variant="contained">
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<AnnotationDialog
|
||||
open={annotationOpen}
|
||||
onClose={closeAnnotation}
|
||||
image={currentImage}
|
||||
templates={templates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onTemplateChange={handleTemplateChange}
|
||||
answer={answer}
|
||||
onAnswerChange={setAnswer}
|
||||
onSave={() => saveAnnotation(false)}
|
||||
onSaveAndContinue={() => saveAnnotation(true)}
|
||||
saving={annotationSaving}
|
||||
loading={annotationLoading}
|
||||
onOpenCreateQuestion={handleOpenCreateDialog}
|
||||
onOpenCreateTemplate={handleOpenCreateTemplateDialog}
|
||||
/>
|
||||
|
||||
{/* 问题编辑对话框 */}
|
||||
<QuestionEditDialog
|
||||
open={editDialogOpen}
|
||||
mode={editMode}
|
||||
question={editingQuestion}
|
||||
onClose={handleCloseDialog}
|
||||
onSubmit={handleSubmitQuestion}
|
||||
projectId={projectId}
|
||||
initialData={{ sourceType: 'image', imageId: currentImage?.id }}
|
||||
/>
|
||||
|
||||
{/* 问题模板对话框 */}
|
||||
<TemplateFormDialog
|
||||
open={templateDialogOpen}
|
||||
onClose={handleCloseTemplateDialog}
|
||||
onSubmit={handleSubmitTemplate}
|
||||
projectId={projectId}
|
||||
template={{ sourceType: 'image' }}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 图片管理页面样式配置
|
||||
*/
|
||||
|
||||
export const imageStyles = {
|
||||
// 页面容器
|
||||
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'
|
||||
},
|
||||
|
||||
actionButton: {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
px: 3,
|
||||
fontWeight: 600,
|
||||
boxShadow: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 4
|
||||
}
|
||||
},
|
||||
|
||||
// 筛选区域
|
||||
filterCard: {
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
boxShadow: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'visible'
|
||||
},
|
||||
|
||||
filterContent: {
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
|
||||
searchField: {
|
||||
minWidth: { xs: '100%', sm: 300 },
|
||||
flex: { xs: '1 1 100%', sm: '1 1 auto' },
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2
|
||||
}
|
||||
},
|
||||
|
||||
filterSelect: {
|
||||
minWidth: { xs: '48%', sm: 150 },
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2
|
||||
}
|
||||
},
|
||||
|
||||
viewToggle: {
|
||||
ml: 'auto',
|
||||
borderRadius: 2,
|
||||
'& .MuiToggleButton-root': {
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 图片网格
|
||||
gridContainer: {
|
||||
spacing: 3
|
||||
},
|
||||
|
||||
// 图片卡片
|
||||
imageCard: {
|
||||
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',
|
||||
'&: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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
imageWrapper: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'grey.100'
|
||||
},
|
||||
|
||||
imageMedia: {
|
||||
height: 220,
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)'
|
||||
}
|
||||
},
|
||||
|
||||
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,
|
||||
pb: 1.5
|
||||
},
|
||||
|
||||
imageName: {
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.4
|
||||
},
|
||||
|
||||
cardActions: {
|
||||
p: 2,
|
||||
pt: 0,
|
||||
gap: 1,
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
|
||||
actionIconButton: {
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)'
|
||||
}
|
||||
},
|
||||
|
||||
primaryActionButton: {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
// 分页
|
||||
pagination: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mt: 4
|
||||
},
|
||||
|
||||
// 空状态
|
||||
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
|
||||
},
|
||||
|
||||
emptyButton: {
|
||||
borderRadius: 2,
|
||||
px: 4,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600
|
||||
},
|
||||
|
||||
// 加载状态
|
||||
loadingContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
py: 8
|
||||
}
|
||||
};
|
||||
125
easy-dataset-main/app/projects/[projectId]/layout.js
Normal file
125
easy-dataset-main/app/projects/[projectId]/layout.js
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import Navbar from '@/components/Navbar/index';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, CircularProgress, Typography, Button } from '@mui/material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
export default function ProjectLayout({ children, params }) {
|
||||
const router = useRouter();
|
||||
const { projectId } = params;
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [currentProject, setCurrentProject] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [t] = useTranslation();
|
||||
const setModelConfigList = useSetAtom(modelConfigListAtom);
|
||||
const setSelectedModelInfo = useSetAtom(selectedModelInfoAtom);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [projectsResponse, projectResponse, modelConfigResponse] = await Promise.all([
|
||||
fetch('/api/projects'),
|
||||
fetch(`/api/projects/${projectId}`),
|
||||
fetch(`/api/projects/${projectId}/model-config`)
|
||||
]);
|
||||
|
||||
if (!projectsResponse.ok) {
|
||||
throw new Error(t('projects.fetchFailed'));
|
||||
}
|
||||
const projectsData = await projectsResponse.json();
|
||||
setProjects(projectsData);
|
||||
|
||||
if (!projectResponse.ok) {
|
||||
if (projectResponse.status === 404) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load project details');
|
||||
}
|
||||
const projectData = await projectResponse.json();
|
||||
setCurrentProject(projectData);
|
||||
|
||||
if (modelConfigResponse.ok) {
|
||||
const modelConfigData = await modelConfigResponse.json();
|
||||
const modelList = Array.isArray(modelConfigData?.data) ? modelConfigData.data : [];
|
||||
setModelConfigList(modelList);
|
||||
if (modelConfigData?.defaultModelConfigId) {
|
||||
const defaultModel = modelList.find(item => item.id === modelConfigData.defaultModelConfigId);
|
||||
setSelectedModelInfo(defaultModel || null);
|
||||
} else {
|
||||
setSelectedModelInfo(null);
|
||||
}
|
||||
} else {
|
||||
setModelConfigList([]);
|
||||
setSelectedModelInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load project data:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || projectId === 'undefined') {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [projectId, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh'
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ mt: 2 }}>Loading project data...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh'
|
||||
}}
|
||||
>
|
||||
<Typography color="error">
|
||||
{t('projects.fetchFailed')}: {error}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => router.push('/')} sx={{ mt: 2 }}>
|
||||
{t('projects.backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar projects={projects} currentProject={projectId} />
|
||||
<Box component="main" sx={{ pt: 2 }}>
|
||||
{children}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import ConversationHeader from '@/components/conversations/ConversationHeader';
|
||||
import ConversationMetadata from '@/components/conversations/ConversationMetadata';
|
||||
import ConversationContent from '@/components/conversations/ConversationContent';
|
||||
import ConversationRatingSection from '@/components/conversations/ConversationRatingSection';
|
||||
import useConversationDetails from './useConversationDetails';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 多轮对话详情页面
|
||||
*/
|
||||
export default function ConversationDetailPage({ params }) {
|
||||
const { projectId, conversationId } = params;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 使用自定义Hook管理状态和逻辑
|
||||
const {
|
||||
conversation,
|
||||
messages,
|
||||
loading,
|
||||
editMode,
|
||||
saving,
|
||||
editData,
|
||||
setEditData,
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
handleEdit,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
handleDelete,
|
||||
handleNavigate,
|
||||
updateMessageContent
|
||||
} = useConversationDetails(projectId, conversationId);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
|
||||
<Alert severity="info">{t('datasets.loadingDataset')}</Alert>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据状态
|
||||
if (!conversation) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Alert severity="error">{t('datasets.conversationNotFound')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{/* 顶部导航栏 */}
|
||||
<ConversationHeader
|
||||
projectId={projectId}
|
||||
conversationId={conversationId}
|
||||
conversation={conversation}
|
||||
editMode={editMode}
|
||||
saving={saving}
|
||||
onEdit={handleEdit}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={() => setDeleteDialogOpen(true)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
|
||||
{/* 主要布局:左右分栏 */}
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* 左侧主要内容区域 */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{/* 对话内容 */}
|
||||
<ConversationContent
|
||||
messages={editMode ? editData.messages : messages}
|
||||
editMode={editMode}
|
||||
onMessageChange={updateMessageContent}
|
||||
conversation={conversation}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* 右侧固定侧边栏 */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 360,
|
||||
position: 'sticky',
|
||||
top: 24,
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* 元数据展示 */}
|
||||
<ConversationMetadata conversation={conversation} />
|
||||
|
||||
{/* 评分、标签、备注区域 */}
|
||||
<ConversationRatingSection
|
||||
conversation={conversation}
|
||||
projectId={projectId}
|
||||
onUpdate={() => {
|
||||
// 更新成功后刷新数据,保持页面状态同步
|
||||
// 这里可以调用 useConversationDetails 的刷新逻辑
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>{t('datasets.confirmDelete')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{t('datasets.confirmDeleteConversation')}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button color="error" onClick={handleDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 多轮对话详情页面的状态管理Hook
|
||||
*/
|
||||
export default function useConversationDetails(projectId, conversationId) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
// 基础状态
|
||||
const [conversation, setConversation] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 编辑状态
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editData, setEditData] = useState({
|
||||
score: 0,
|
||||
tags: '',
|
||||
note: '',
|
||||
confirmed: false,
|
||||
messages: []
|
||||
});
|
||||
|
||||
// 对话框状态
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// 获取对话详情
|
||||
const fetchConversation = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
toast.error(t('datasets.conversationNotFound'));
|
||||
router.push(`/projects/${projectId}/multi-turn`);
|
||||
return;
|
||||
}
|
||||
throw new Error(t('datasets.fetchDataFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setConversation(data);
|
||||
|
||||
// 解析对话消息
|
||||
let parsedMessages = [];
|
||||
try {
|
||||
parsedMessages = JSON.parse(data.rawMessages || '[]');
|
||||
setMessages(parsedMessages);
|
||||
} catch (error) {
|
||||
console.error('解析对话消息失败:', error);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
// 设置编辑数据
|
||||
setEditData({
|
||||
score: data.score || 0,
|
||||
tags: data.tags || '',
|
||||
note: data.note || '',
|
||||
confirmed: data.confirmed || false,
|
||||
messages: parsedMessages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取对话详情失败:', error);
|
||||
toast.error(error.message || t('datasets.fetchDataFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
score: editData.score,
|
||||
tags: editData.tags,
|
||||
note: editData.note,
|
||||
confirmed: editData.confirmed,
|
||||
messages: editData.messages
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('datasets.saveFailed'));
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setConversation({ ...conversation, ...editData });
|
||||
setMessages(editData.messages);
|
||||
setEditMode(false);
|
||||
toast.success(t('datasets.saveSuccess'));
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
toast.error(error.message || t('datasets.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始编辑
|
||||
const handleEdit = () => {
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancel = () => {
|
||||
// 恢复到原始数据
|
||||
setEditData({
|
||||
score: conversation.score || 0,
|
||||
tags: conversation.tags || '',
|
||||
note: conversation.note || '',
|
||||
confirmed: conversation.confirmed || false,
|
||||
messages: messages
|
||||
});
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
// 删除对话
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('datasets.deleteFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('datasets.deleteSuccess'));
|
||||
router.push(`/projects/${projectId}/multi-turn`);
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
toast.error(error.message || t('datasets.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 更新消息内容
|
||||
const updateMessageContent = (index, newContent) => {
|
||||
const updatedMessages = [...editData.messages];
|
||||
updatedMessages[index] = { ...updatedMessages[index], content: newContent };
|
||||
setEditData({ ...editData, messages: updatedMessages });
|
||||
};
|
||||
|
||||
// 翻页导航
|
||||
const handleNavigate = async direction => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/dataset-conversations/${conversationId}?operateType=${direction}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取导航数据失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data) {
|
||||
router.push(`/projects/${projectId}/multi-turn/${data.id}`);
|
||||
} else {
|
||||
toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条对话了`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导航失败:', error);
|
||||
toast.error(error.message || '导航失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
fetchConversation();
|
||||
}, [projectId, conversationId]);
|
||||
|
||||
return {
|
||||
// 数据状态
|
||||
conversation,
|
||||
messages,
|
||||
loading,
|
||||
|
||||
// 编辑状态
|
||||
editMode,
|
||||
saving,
|
||||
editData,
|
||||
setEditData,
|
||||
|
||||
// 对话框状态
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
|
||||
// 操作方法
|
||||
handleEdit,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
handleDelete,
|
||||
handleNavigate,
|
||||
updateMessageContent,
|
||||
fetchConversation
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Checkbox
|
||||
} from '@mui/material';
|
||||
import { Delete as DeleteIcon, Visibility as ViewIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import RatingChip from './RatingChip';
|
||||
|
||||
const QUESTION_TOOLTIP_THRESHOLD = 80;
|
||||
const SCENARIO_TOOLTIP_THRESHOLD = 120;
|
||||
|
||||
const ConversationTable = ({
|
||||
conversations,
|
||||
loading,
|
||||
total,
|
||||
page,
|
||||
rowsPerPage,
|
||||
onPageChange,
|
||||
onRowsPerPageChange,
|
||||
onView,
|
||||
onDelete,
|
||||
selectedIds = [],
|
||||
onSelectionChange,
|
||||
isAllSelected = false,
|
||||
onSelectAll
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [expandedRows, setExpandedRows] = useState({});
|
||||
const columnWidths = {
|
||||
checkbox: 52,
|
||||
question: 280,
|
||||
scenario: 340,
|
||||
rounds: 90,
|
||||
model: 120,
|
||||
rating: 100,
|
||||
createdAt: 110,
|
||||
actions: 92
|
||||
};
|
||||
|
||||
const shouldShowTooltip = (value, threshold) => (value || '').length > threshold;
|
||||
|
||||
const handleSelectOne = conversationId => {
|
||||
if (selectedIds.includes(conversationId)) {
|
||||
onSelectionChange(selectedIds.filter(id => id !== conversationId));
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, conversationId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
onSelectionChange([]);
|
||||
onSelectAll(false);
|
||||
} else {
|
||||
const currentPageIds = conversations.map(conv => conv.id);
|
||||
onSelectionChange(currentPageIds);
|
||||
onSelectAll(true);
|
||||
}
|
||||
};
|
||||
|
||||
const isIndeterminate = selectedIds.length > 0 && !isAllSelected;
|
||||
const toggleRowExpanded = conversationId => {
|
||||
setExpandedRows(prev => ({ ...prev, [conversationId]: !prev[conversationId] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} elevation={0} sx={{ overflowX: 'auto' }}>
|
||||
<Table
|
||||
sx={{
|
||||
tableLayout: 'fixed',
|
||||
width: '100%',
|
||||
minWidth: 1184,
|
||||
'& .MuiTableCell-root': {
|
||||
px: 1.25,
|
||||
py: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableCell padding="checkbox" sx={{ width: columnWidths.checkbox, py: 1.25 }}>
|
||||
<Checkbox indeterminate={isIndeterminate} checked={isAllSelected} onChange={handleSelectAll} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: columnWidths.question, minWidth: columnWidths.question, py: 1.25 }}>
|
||||
{t('datasets.firstQuestion')}
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: columnWidths.scenario, minWidth: columnWidths.scenario, py: 1.25 }}>
|
||||
{t('datasets.conversationScenario')}
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: columnWidths.rounds, py: 1.25 }}>{t('datasets.conversationRounds')}</TableCell>
|
||||
<TableCell sx={{ width: columnWidths.model, py: 1.25 }}>{t('datasets.modelUsed')}</TableCell>
|
||||
<TableCell sx={{ width: columnWidths.rating, py: 1.25 }}>{t('datasets.rating')}</TableCell>
|
||||
<TableCell sx={{ width: columnWidths.createdAt, py: 1.25 }}>{t('datasets.createTime')}</TableCell>
|
||||
<TableCell
|
||||
align="center"
|
||||
sx={{
|
||||
width: columnWidths.actions,
|
||||
py: 1.25,
|
||||
position: 'sticky',
|
||||
right: 0,
|
||||
zIndex: 3,
|
||||
bgcolor: 'background.paper',
|
||||
borderLeft: 1,
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{t('common.actions')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
|
||||
<CircularProgress size={40} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : conversations.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('datasets.noConversations')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
conversations.map(conversation => {
|
||||
const questionText = conversation.question || '';
|
||||
const scenarioText = conversation.scenario || '';
|
||||
const isExpanded = Boolean(expandedRows[conversation.id]);
|
||||
const canToggleExpand =
|
||||
questionText.length > QUESTION_TOOLTIP_THRESHOLD || scenarioText.length > SCENARIO_TOOLTIP_THRESHOLD;
|
||||
|
||||
const questionContent = (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: isExpanded ? 'unset' : 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'normal',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
{questionText}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const scenarioContent = (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
maxWidth: '100%',
|
||||
borderColor: scenarioText ? 'primary.main' : 'divider',
|
||||
backgroundColor: scenarioText ? 'action.selected' : 'background.default'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: isExpanded ? 'unset' : 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'normal',
|
||||
lineHeight: 1.45
|
||||
}}
|
||||
>
|
||||
{scenarioText || t('datasets.notSet')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={conversation.id} hover>
|
||||
<TableCell padding="checkbox" sx={{ verticalAlign: 'top' }}>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(conversation.id)}
|
||||
onChange={() => handleSelectOne(conversation.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'top' }}>
|
||||
{shouldShowTooltip(questionText, QUESTION_TOOLTIP_THRESHOLD) ? (
|
||||
<Tooltip title={questionText} placement="top-start">
|
||||
{questionContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
questionContent
|
||||
)}
|
||||
{conversation.confirmed && (
|
||||
<Chip
|
||||
label={t('datasets.confirmed')}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ mt: 0.5, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{canToggleExpand && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="primary.main"
|
||||
sx={{ display: 'block', mt: 0.5, cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => toggleRowExpanded(conversation.id)}
|
||||
>
|
||||
{isExpanded ? t('common.collapse') : t('common.expand')}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'top' }}>
|
||||
{shouldShowTooltip(scenarioText, SCENARIO_TOOLTIP_THRESHOLD) ? (
|
||||
<Tooltip title={scenarioText} placement="top-start">
|
||||
{scenarioContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
scenarioContent
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'top' }}>
|
||||
<Typography variant="body2">
|
||||
{conversation.turnCount}/{conversation.maxTurns}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'top' }}>
|
||||
<Chip
|
||||
label={conversation.model}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
'& .MuiChip-label': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'top' }}>
|
||||
<RatingChip score={conversation.score || 0} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'top' }}>
|
||||
<Typography variant="caption">{new Date(conversation.createAt).toLocaleDateString()}</Typography>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="center"
|
||||
sx={{
|
||||
verticalAlign: 'top',
|
||||
position: 'sticky',
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderLeft: 1,
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('datasets.viewDetails')}>
|
||||
<IconButton size="small" color="primary" onClick={() => onView(conversation.id)}>
|
||||
<ViewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(conversation.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(event, newPage) => onPageChange(newPage)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
rowsPerPageOptions={[20, 50, 100]}
|
||||
onRowsPerPageChange={event => {
|
||||
onRowsPerPageChange(parseInt(event.target.value, 10));
|
||||
}}
|
||||
labelRowsPerPage={t('datasets.rowsPerPage')}
|
||||
/>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationTable;
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 筛选对话框组件
|
||||
* @param {boolean} open - 对话框开启状态
|
||||
* @param {function} onClose - 关闭回调
|
||||
* @param {object} filters - 筛选条件
|
||||
* @param {function} onFiltersChange - 筛选条件变化回调
|
||||
* @param {function} onReset - 重置回调
|
||||
* @param {function} onApply - 应用回调
|
||||
*/
|
||||
const FilterDialog = ({ open, onClose, filters, onFiltersChange, onReset, onApply }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFilterChange = (field, value) => {
|
||||
onFiltersChange({ ...filters, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('datasets.filtersTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField
|
||||
label={t('settings.multiTurnRoleA')}
|
||||
value={filters.roleA}
|
||||
onChange={e => handleFilterChange('roleA', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('settings.multiTurnRoleB')}
|
||||
value={filters.roleB}
|
||||
onChange={e => handleFilterChange('roleB', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('datasets.conversationScenario')}
|
||||
value={filters.scenario}
|
||||
onChange={e => handleFilterChange('scenario', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label={t('datasets.minScore')}
|
||||
type="number"
|
||||
inputProps={{ min: 0, max: 5, step: 0.1 }}
|
||||
value={filters.scoreMin}
|
||||
onChange={e => handleFilterChange('scoreMin', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('datasets.maxScore')}
|
||||
type="number"
|
||||
inputProps={{ min: 0, max: 5, step: 0.1 }}
|
||||
value={filters.scoreMax}
|
||||
onChange={e => handleFilterChange('scoreMax', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('datasets.filterConfirmationStatus')}</InputLabel>
|
||||
<Select
|
||||
value={filters.confirmed}
|
||||
onChange={e => handleFilterChange('confirmed', e.target.value)}
|
||||
label={t('datasets.filterConfirmationStatus')}
|
||||
>
|
||||
<MenuItem value="">{t('datasetSquare.categories.all')}</MenuItem>
|
||||
<MenuItem value="true">{t('datasets.confirmed')}</MenuItem>
|
||||
<MenuItem value="false">{t('datasets.unconfirmed')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onReset} sx={{ borderRadius: 2 }}>
|
||||
{t('datasets.resetFilters')}
|
||||
</Button>
|
||||
<Button onClick={onClose} sx={{ borderRadius: 2 }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onApply} sx={{ borderRadius: 2 }}>
|
||||
{t('datasets.applyFilters')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterDialog;
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Chip } from '@mui/material';
|
||||
import { Star as StarIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getRatingConfigI18n, formatScore } from '@/components/datasets/utils/ratingUtils';
|
||||
|
||||
/**
|
||||
* 评分展示组件
|
||||
* @param {number} score - 评分值
|
||||
*/
|
||||
const RatingChip = ({ score }) => {
|
||||
const { t } = useTranslation();
|
||||
const config = getRatingConfigI18n(score, t);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={<StarIcon sx={{ fontSize: '14px !important' }} />}
|
||||
label={`${formatScore(score)} ${config.label}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: config.backgroundColor,
|
||||
color: config.color,
|
||||
fontWeight: 'medium',
|
||||
'& .MuiChip-icon': {
|
||||
color: config.color
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingChip;
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Paper, Button, IconButton, InputBase, CircularProgress } from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
FilterList as FilterIcon,
|
||||
Download as DownloadIcon,
|
||||
Delete as DeleteIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 搜索栏组件
|
||||
* @param {string} searchKeyword - 搜索关键词
|
||||
* @param {function} onSearchChange - 搜索关键词变化回调
|
||||
* @param {function} onSearch - 搜索回调
|
||||
* @param {function} onFilterClick - 筛选按钮点击回调
|
||||
* @param {function} onExportClick - 导出按钮点击回调
|
||||
* @param {boolean} exportLoading - 导出加载状态
|
||||
* @param {number} selectedCount - 选中的项目数量
|
||||
* @param {function} onBatchDelete - 批量删除回调
|
||||
* @param {boolean} batchDeleteLoading - 批量删除加载状态
|
||||
*/
|
||||
const SearchBar = ({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
onSearch,
|
||||
onFilterClick,
|
||||
onExportClick,
|
||||
exportLoading = false,
|
||||
selectedCount = 0,
|
||||
onBatchDelete,
|
||||
batchDeleteLoading = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'space-between' }}>
|
||||
<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('datasets.searchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
onKeyPress={e => e.key === 'Enter' && onSearch()}
|
||||
/>
|
||||
</Paper>
|
||||
<Button variant="outlined" startIcon={<FilterIcon />} onClick={onFilterClick} sx={{ borderRadius: 2 }}>
|
||||
{t('datasets.moreFilters')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{selectedCount > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={batchDeleteLoading ? <CircularProgress size={16} /> : <DeleteIcon />}
|
||||
onClick={onBatchDelete}
|
||||
disabled={batchDeleteLoading}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{batchDeleteLoading ? t('datasets.deleting') : `${t('datasets.batchDelete')} (${selectedCount})`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={exportLoading ? <CircularProgress size={16} /> : <DownloadIcon />}
|
||||
onClick={onExportClick}
|
||||
disabled={exportLoading}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{exportLoading ? t('datasets.exporting') : t('exportDialog.export')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Multi-turn dataset data hook
|
||||
* @param {string} projectId
|
||||
*/
|
||||
export const useMultiTurnData = projectId => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(20);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [isAllSelected, setIsAllSelected] = useState(false);
|
||||
const [batchDeleteLoading, setBatchDeleteLoading] = useState(false);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
roleA: '',
|
||||
roleB: '',
|
||||
scenario: '',
|
||||
scoreMin: '',
|
||||
scoreMax: '',
|
||||
confirmed: ''
|
||||
});
|
||||
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const buildQuery = ({ pageIndex, keyword, filterValues }) => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(pageIndex + 1),
|
||||
pageSize: String(rowsPerPage)
|
||||
});
|
||||
|
||||
if (keyword) params.append('keyword', keyword);
|
||||
if (filterValues.roleA) params.append('roleA', filterValues.roleA);
|
||||
if (filterValues.roleB) params.append('roleB', filterValues.roleB);
|
||||
if (filterValues.scenario) params.append('scenario', filterValues.scenario);
|
||||
if (filterValues.scoreMin) params.append('scoreMin', filterValues.scoreMin);
|
||||
if (filterValues.scoreMax) params.append('scoreMax', filterValues.scoreMax);
|
||||
if (filterValues.confirmed) params.append('confirmed', filterValues.confirmed);
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const fetchConversations = async (newPage = page, options = {}) => {
|
||||
const keyword = options.keyword ?? searchKeyword;
|
||||
const filterValues = options.filterValues ?? filters;
|
||||
const showLoading = options.showLoading ?? true;
|
||||
|
||||
try {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const params = buildQuery({ pageIndex: newPage, keyword, filterValues });
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations?${params.toString()}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('datasets.fetchDataFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setConversations(data.data || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
console.error('Failed to fetch multi-turn dataset list:', error);
|
||||
toast.error(error.message || t('datasets.fetchDataFailed'));
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setExportLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/export`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('datasets.exportFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const dataStr = JSON.stringify(data, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `multi-turn-conversations-${projectId}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(t('datasets.exportSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
toast.error(error.message || t('datasets.exportFailed'));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllConversationIds = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ getAllIds: 'true' });
|
||||
if (searchKeyword) params.append('keyword', searchKeyword);
|
||||
if (filters.roleA) params.append('roleA', filters.roleA);
|
||||
if (filters.roleB) params.append('roleB', filters.roleB);
|
||||
if (filters.scenario) params.append('scenario', filters.scenario);
|
||||
if (filters.scoreMin) params.append('scoreMin', filters.scoreMin);
|
||||
if (filters.scoreMax) params.append('scoreMax', filters.scoreMax);
|
||||
if (filters.confirmed) params.append('confirmed', filters.confirmed);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(t('datasets.fetchDataFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.allConversationIds || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all conversation IDs:', error);
|
||||
toast.error(error.message || t('datasets.fetchDataFailed'));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async conversationId => {
|
||||
if (!confirm(t('datasets.confirmDeleteConversation'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('datasets.deleteFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('datasets.deleteSuccess'));
|
||||
fetchConversations();
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
toast.error(error.message || t('datasets.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteConversationsConcurrently = async (conversationIds, concurrency = 10) => {
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < conversationIds.length; i += concurrency) {
|
||||
const batch = conversationIds.slice(i, i + concurrency);
|
||||
const promises = batch.map(async id => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Delete conversation ${id} failed`);
|
||||
}
|
||||
return { id, success: true };
|
||||
} catch (error) {
|
||||
errors.push({ id, error: error.message });
|
||||
return { id, success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(promises);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return { results, errors };
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
let idsToDelete = selectedIds;
|
||||
|
||||
if (isAllSelected) {
|
||||
idsToDelete = await fetchAllConversationIds();
|
||||
if (idsToDelete.length === 0) {
|
||||
toast.error(t('datasets.noDataToDelete'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToDelete.length === 0) {
|
||||
toast.error(t('datasets.pleaseSelectData'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(t('common.confirmDelete', { count: idsToDelete.length }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBatchDeleteLoading(true);
|
||||
const { results, errors } = await deleteConversationsConcurrently(idsToDelete);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = errors.length;
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(t('common.deleteSuccess', { count: successCount }));
|
||||
} else {
|
||||
toast.warning(t('datasets.batchDeletePartialSuccess', { success: successCount, fail: failCount }));
|
||||
}
|
||||
|
||||
setSelectedIds([]);
|
||||
setIsAllSelected(false);
|
||||
fetchConversations();
|
||||
} catch (error) {
|
||||
console.error('Batch delete failed:', error);
|
||||
toast.error(error.message || t('datasets.batchDeleteFailed'));
|
||||
} finally {
|
||||
setBatchDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectionChange = newSelectedIds => {
|
||||
setSelectedIds(newSelectedIds);
|
||||
if (newSelectedIds.length === 0) {
|
||||
setIsAllSelected(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = selectAll => {
|
||||
setIsAllSelected(selectAll);
|
||||
if (!selectAll) {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = conversationId => {
|
||||
router.push(`/projects/${projectId}/multi-turn/${conversationId}`);
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
setPage(0);
|
||||
setFilterDialogOpen(false);
|
||||
fetchConversations(0, { keyword: searchKeyword, filterValues: filters });
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
const clearedFilters = {
|
||||
roleA: '',
|
||||
roleB: '',
|
||||
scenario: '',
|
||||
scoreMin: '',
|
||||
scoreMax: '',
|
||||
confirmed: ''
|
||||
};
|
||||
setFilters(clearedFilters);
|
||||
setSearchKeyword('');
|
||||
setPage(0);
|
||||
fetchConversations(0, { keyword: '', filterValues: clearedFilters });
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(0);
|
||||
fetchConversations(0, { keyword: searchKeyword, filterValues: filters });
|
||||
};
|
||||
|
||||
const handlePageChange = newPage => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleRowsPerPageChange = newRowsPerPage => {
|
||||
setRowsPerPage(newRowsPerPage);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations(page, { showLoading: true });
|
||||
}, [projectId, page, rowsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
page,
|
||||
rowsPerPage,
|
||||
total,
|
||||
searchKeyword,
|
||||
filterDialogOpen,
|
||||
exportLoading,
|
||||
filters,
|
||||
|
||||
selectedIds,
|
||||
isAllSelected,
|
||||
batchDeleteLoading,
|
||||
|
||||
setSearchKeyword,
|
||||
setFilterDialogOpen,
|
||||
setFilters,
|
||||
|
||||
fetchConversations,
|
||||
handleExport,
|
||||
handleDelete,
|
||||
handleView,
|
||||
applyFilters,
|
||||
resetFilters,
|
||||
handleSearch,
|
||||
handlePageChange,
|
||||
handleRowsPerPageChange,
|
||||
|
||||
handleBatchDelete,
|
||||
handleSelectionChange,
|
||||
handleSelectAll
|
||||
};
|
||||
};
|
||||
106
easy-dataset-main/app/projects/[projectId]/multi-turn/page.js
Normal file
106
easy-dataset-main/app/projects/[projectId]/multi-turn/page.js
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Typography, Box, Card, useTheme, alpha } from '@mui/material';
|
||||
import { Chat as ChatIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 导入拆分后的组件
|
||||
import SearchBar from './components/SearchBar';
|
||||
import ConversationTable from './components/ConversationTable';
|
||||
import FilterDialog from './components/FilterDialog';
|
||||
import { useMultiTurnData } from './hooks/useMultiTurnData';
|
||||
|
||||
export default function MultiTurnDatasetPage({ params }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { projectId } = params;
|
||||
|
||||
// 使用自定义Hook管理状态和逻辑
|
||||
const {
|
||||
conversations,
|
||||
loading,
|
||||
page,
|
||||
rowsPerPage,
|
||||
total,
|
||||
searchKeyword,
|
||||
filterDialogOpen,
|
||||
exportLoading,
|
||||
filters,
|
||||
selectedIds,
|
||||
isAllSelected,
|
||||
batchDeleteLoading,
|
||||
setSearchKeyword,
|
||||
setFilterDialogOpen,
|
||||
setFilters,
|
||||
fetchConversations,
|
||||
handleExport,
|
||||
handleDelete,
|
||||
handleView,
|
||||
applyFilters,
|
||||
resetFilters,
|
||||
handleSearch,
|
||||
handlePageChange,
|
||||
handleRowsPerPageChange,
|
||||
handleBatchDelete,
|
||||
handleSelectionChange,
|
||||
handleSelectAll
|
||||
} = useMultiTurnData(projectId);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 6 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
backgroundColor: alpha(theme.palette.primary.light, 0.05),
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<ChatIcon sx={{ mr: 2, fontSize: 32, color: 'primary.main' }} />
|
||||
<Typography variant="h4" component="h1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('datasets.multiTurnConversations')}
|
||||
</Typography>
|
||||
</Box> */}
|
||||
|
||||
<SearchBar
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
onSearch={handleSearch}
|
||||
onFilterClick={() => setFilterDialogOpen(true)}
|
||||
onExportClick={handleExport}
|
||||
exportLoading={exportLoading}
|
||||
selectedCount={isAllSelected ? total : selectedIds.length}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
batchDeleteLoading={batchDeleteLoading}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ConversationTable
|
||||
conversations={conversations}
|
||||
loading={loading}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
total={total}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
|
||||
<FilterDialog
|
||||
open={filterDialogOpen}
|
||||
onClose={() => setFilterDialogOpen(false)}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onReset={resetFilters}
|
||||
onApply={applyFilters}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
39
easy-dataset-main/app/projects/[projectId]/page.js
Normal file
39
easy-dataset-main/app/projects/[projectId]/page.js
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useSetAtom } from 'jotai/index';
|
||||
import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
export default function ProjectPage({ params }) {
|
||||
const router = useRouter();
|
||||
const setConfigList = useSetAtom(modelConfigListAtom);
|
||||
const setSelectedModelInfo = useSetAtom(selectedModelInfoAtom);
|
||||
const { projectId } = params;
|
||||
|
||||
// 默认重定向到文本分割页面
|
||||
useEffect(() => {
|
||||
getModelConfigList(projectId);
|
||||
router.push(`/projects/${projectId}/text-split`);
|
||||
}, [projectId, router]);
|
||||
|
||||
const getModelConfigList = projectId => {
|
||||
axios
|
||||
.get(`/api/projects/${projectId}/model-config`)
|
||||
.then(response => {
|
||||
setConfigList(response.data.data);
|
||||
if (response.data.defaultModelConfigId) {
|
||||
setSelectedModelInfo(response.data.data.find(item => item.id === response.data.defaultModelConfigId));
|
||||
} else {
|
||||
setSelectedModelInfo(null);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error('get model list error');
|
||||
});
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Alert } from '@mui/material';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ChatArea from '@/components/playground/ChatArea';
|
||||
import MessageInput from '@/components/playground/MessageInput';
|
||||
import PlaygroundHeader from '@/components/playground/PlaygroundHeader';
|
||||
import useModelPlayground from '@/hooks/useModelPlayground';
|
||||
import { playgroundStyles } from '@/styles/playground';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { modelConfigListAtom } from '@/lib/store';
|
||||
|
||||
export default function ModelPlayground({ searchParams }) {
|
||||
const theme = useTheme();
|
||||
const params = useParams();
|
||||
const { projectId } = params;
|
||||
const modelId = searchParams?.modelId || null;
|
||||
const styles = playgroundStyles(theme);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
selectedModels,
|
||||
loading,
|
||||
userInput,
|
||||
conversations,
|
||||
error,
|
||||
outputMode,
|
||||
uploadedImage,
|
||||
handleModelSelection,
|
||||
handleInputChange,
|
||||
handleImageUpload,
|
||||
handleRemoveImage,
|
||||
handleSendMessage,
|
||||
handleClearConversations,
|
||||
handleOutputModeChange
|
||||
} = useModelPlayground(projectId, modelId);
|
||||
|
||||
const availableModels = useAtomValue(modelConfigListAtom);
|
||||
|
||||
// 获取模型名称
|
||||
const getModelName = modelId => {
|
||||
const model = availableModels.find(m => m.id === modelId);
|
||||
return model ? `${model.providerName}: ${model.modelName}` : modelId;
|
||||
};
|
||||
return (
|
||||
<Box sx={styles.container}>
|
||||
<Typography variant="h5" component="h1" gutterBottom>
|
||||
{t('playground.title')}
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper elevation={2} sx={styles.mainPaper}>
|
||||
<PlaygroundHeader
|
||||
availableModels={availableModels}
|
||||
selectedModels={selectedModels}
|
||||
handleModelSelection={handleModelSelection}
|
||||
handleClearConversations={handleClearConversations}
|
||||
conversations={conversations}
|
||||
outputMode={outputMode}
|
||||
handleOutputModeChange={handleOutputModeChange}
|
||||
/>
|
||||
|
||||
<ChatArea
|
||||
selectedModels={selectedModels}
|
||||
conversations={conversations}
|
||||
loading={loading}
|
||||
getModelName={getModelName}
|
||||
/>
|
||||
|
||||
<MessageInput
|
||||
userInput={userInput}
|
||||
handleInputChange={handleInputChange}
|
||||
handleSendMessage={handleSendMessage}
|
||||
loading={loading}
|
||||
selectedModels={selectedModels}
|
||||
uploadedImage={uploadedImage}
|
||||
handleImageUpload={handleImageUpload}
|
||||
handleRemoveImage={handleRemoveImage}
|
||||
availableModels={availableModels}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 确认对话框组件
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调函数
|
||||
* @param {Function} props.onConfirm - 确认操作的回调函数
|
||||
* @param {string} props.title - 对话框标题
|
||||
* @param {string} props.content - 对话框内容
|
||||
* @param {string} props.confirmText - 确认按钮文本,默认为 "确认删除"
|
||||
* @param {string} props.cancelText - 取消按钮文本,默认为 "取消"
|
||||
* @param {string} props.confirmColor - 确认按钮颜色,默认为 "error"
|
||||
*/
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
content,
|
||||
confirmText,
|
||||
cancelText,
|
||||
confirmColor = 'error'
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConfirm = () => {
|
||||
onClose();
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-description"
|
||||
>
|
||||
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="confirm-dialog-description">{content}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="primary">
|
||||
{cancelText || t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} color={confirmColor} variant="contained" autoFocus>
|
||||
{confirmText || t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
Box,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
export default function ExportQuestionsDialog({ open, onClose, onExport, selectedCount, totalCount }) {
|
||||
const { t } = useTranslation();
|
||||
const [format, setFormat] = useState('json');
|
||||
const [exportScope, setExportScope] = useState('all');
|
||||
|
||||
const handleExport = () => {
|
||||
const exportOptions = {
|
||||
format,
|
||||
selectedIds: exportScope === 'selected' ? [] : undefined
|
||||
};
|
||||
|
||||
onExport(exportOptions);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('questions.exportQuestions')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}>
|
||||
{/* 导出范围 */}
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">{t('questions.exportScope')}</FormLabel>
|
||||
<RadioGroup value={exportScope} onChange={e => setExportScope(e.target.value)}>
|
||||
<FormControlLabel
|
||||
value="all"
|
||||
control={<Radio />}
|
||||
label={t('questions.exportAll', { count: totalCount })}
|
||||
/>
|
||||
{selectedCount > 0 && (
|
||||
<FormControlLabel
|
||||
value="selected"
|
||||
control={<Radio />}
|
||||
label={t('questions.exportSelected', { count: selectedCount })}
|
||||
/>
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 导出格式 */}
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">{t('questions.exportFormat')}</FormLabel>
|
||||
<RadioGroup value={format} onChange={e => setFormat(e.target.value)}>
|
||||
<FormControlLabel value="json" control={<Radio />} label="JSON" />
|
||||
<FormControlLabel value="jsonl" control={<Radio />} label="JSONL" />
|
||||
<FormControlLabel value="txt" control={<Radio />} label={t('questions.txtFormat')} />
|
||||
<FormControlLabel value="csv" control={<Radio />} label="CSV" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleExport} variant="contained" startIcon={<DownloadIcon />}>
|
||||
{t('export.title')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
Autocomplete,
|
||||
TextField as MuiTextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function QuestionEditDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
initialData,
|
||||
projectId,
|
||||
tags,
|
||||
mode = 'create' // 'create' or 'edit'
|
||||
}) {
|
||||
const [chunks, setChunks] = useState([]);
|
||||
const [images, setImages] = useState([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 获取文本块的标题
|
||||
const getChunkTitle = chunkId => {
|
||||
const chunk = chunks.find(c => c.id === chunkId);
|
||||
return chunk?.name || chunkId; // 直接使用文件名
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
question: '',
|
||||
sourceType: 'text', // 新增:数据源类型
|
||||
chunkId: '',
|
||||
imageId: '', // 新增:图片ID
|
||||
label: '' // 默认不选中任何标签
|
||||
});
|
||||
|
||||
const getChunks = async projectId => {
|
||||
// 获取文本块列表
|
||||
const response = await axios.get(`/api/projects/${projectId}/split`);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(t('common.fetchError'));
|
||||
}
|
||||
setChunks(response.data.chunks || []);
|
||||
};
|
||||
|
||||
const getImages = async projectId => {
|
||||
// 获取图片列表(只获取ID和名称)
|
||||
try {
|
||||
const response = await axios.get(`/api/projects/${projectId}/images?page=1&pageSize=10000&simple=true`);
|
||||
if (response.status === 200) {
|
||||
setImages(response.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch images:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getChunks(projectId);
|
||||
getImages(projectId);
|
||||
if (initialData) {
|
||||
// 根据 imageId 判断数据源类型
|
||||
console.log('initialData:', initialData);
|
||||
const sourceType = initialData.imageId ? 'image' : 'text';
|
||||
setFormData({
|
||||
id: initialData.id,
|
||||
question: initialData.question || '',
|
||||
sourceType: sourceType,
|
||||
chunkId: initialData.chunkId || '',
|
||||
imageId: initialData.imageId || '',
|
||||
label: initialData.label || 'other' // 改用 label 而不是 label
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
id: '',
|
||||
question: '',
|
||||
sourceType: 'text',
|
||||
chunkId: '',
|
||||
imageId: '',
|
||||
label: ''
|
||||
});
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const flattenTags = (tags = [], prefix = '') => {
|
||||
let flatTags = [];
|
||||
const traverse = node => {
|
||||
flatTags.push({
|
||||
id: node.label, // 使用标签名作为 id
|
||||
label: node.label, // 直接使用原始标签名
|
||||
originalLabel: node.label
|
||||
});
|
||||
if (node.child && node.child.length > 0) {
|
||||
node.child.forEach(child => traverse(child));
|
||||
}
|
||||
};
|
||||
tags.forEach(tag => traverse(tag));
|
||||
flatTags.push({
|
||||
id: 'other',
|
||||
label: t('datasets.uncategorized'),
|
||||
originalLabel: 'other'
|
||||
});
|
||||
return flatTags;
|
||||
};
|
||||
|
||||
const flattenedTags = useMemo(() => flattenTags(tags), [tags, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{mode === 'create' ? t('questions.createQuestion') : t('questions.editQuestion')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
||||
{/* 数据源类型选择 */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('questions.sourceType', { defaultValue: '数据源类型' })}</InputLabel>
|
||||
<Select
|
||||
value={formData.sourceType}
|
||||
label={t('questions.sourceType', { defaultValue: '数据源类型' })}
|
||||
onChange={e => {
|
||||
setFormData({
|
||||
...formData,
|
||||
sourceType: e.target.value,
|
||||
chunkId: '',
|
||||
imageId: ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MenuItem value="text">{t('questions.template.sourceType.text')}</MenuItem>
|
||||
<MenuItem value="image">{t('questions.template.sourceType.image')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 问题内容 */}
|
||||
<TextField
|
||||
label={t('questions.questionContent')}
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
value={formData.question}
|
||||
onChange={e => setFormData({ ...formData, question: e.target.value })}
|
||||
/>
|
||||
|
||||
{/* 文本块选择(仅当数据源为文本时显示) */}
|
||||
{formData.sourceType === 'text' && (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
options={chunks}
|
||||
getOptionLabel={chunk => getChunkTitle(chunk.id)}
|
||||
value={chunks.find(chunk => chunk.id === formData.chunkId) || null}
|
||||
onChange={(e, newValue) => setFormData({ ...formData, chunkId: newValue ? newValue.id : '' })}
|
||||
renderInput={params => (
|
||||
<MuiTextField {...params} label={t('questions.selectChunk')} placeholder={t('questions.searchChunk')} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片选择(仅当数据源为图片时显示) */}
|
||||
{formData.sourceType === 'image' && (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
options={images}
|
||||
getOptionLabel={image => image.imageName || ''}
|
||||
value={images.find(image => image.id === formData.imageId) || null}
|
||||
onChange={(e, newValue) => setFormData({ ...formData, imageId: newValue ? newValue.id : '' })}
|
||||
renderInput={params => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={t('questions.selectImage', { defaultValue: '选择图片' })}
|
||||
placeholder={t('questions.searchImage', { defaultValue: '搜索图片...' })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标签选择 */}
|
||||
{formData.sourceType === 'text' && (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
options={flattenedTags}
|
||||
getOptionLabel={tag => tag.label}
|
||||
value={flattenedTags.find(tag => tag.id === formData.label) || null}
|
||||
onChange={(e, newValue) => setFormData({ ...formData, label: newValue ? newValue.id : '' })}
|
||||
renderInput={params => (
|
||||
<MuiTextField {...params} label={t('questions.selectTag')} placeholder={t('questions.searchTag')} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={!formData.question || (formData.sourceType === 'text' ? !formData.chunkId : !formData.imageId)}
|
||||
>
|
||||
{mode === 'create' ? t('common.create') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Stack, Checkbox, Typography, TextField, InputAdornment, Select, MenuItem, useTheme } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
|
||||
export default function QuestionsFilter({
|
||||
// 选择相关
|
||||
selectedQuestionsCount,
|
||||
totalQuestions,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
onSelectAll,
|
||||
|
||||
// 搜索相关
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
searchMatchMode,
|
||||
onSearchMatchModeChange,
|
||||
|
||||
// 过滤相关
|
||||
answerFilter,
|
||||
onFilterChange,
|
||||
|
||||
// 文本块名称筛选
|
||||
chunkNameFilter,
|
||||
onChunkNameFilterChange,
|
||||
|
||||
// 数据源类型筛选
|
||||
sourceTypeFilter,
|
||||
onSourceTypeFilterChange,
|
||||
|
||||
activeTab
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (activeTab === 1) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{/* 选择区域 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Checkbox checked={isAllSelected} indeterminate={isIndeterminate} onChange={onSelectAll} />
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||
{selectedQuestionsCount > 0
|
||||
? t('questions.selectedCount', { count: selectedQuestionsCount })
|
||||
: t('questions.selectAll')}
|
||||
(
|
||||
{t('questions.totalCount', {
|
||||
count: totalQuestions
|
||||
})}
|
||||
)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 搜索和过滤区域 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{/* 组合搜索框:下拉选择(匹配/不匹配)+ 输入框 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: { xs: '100%', sm: 300 } }}>
|
||||
<Select
|
||||
value={searchMatchMode}
|
||||
onChange={onSearchMatchModeChange}
|
||||
size="small"
|
||||
sx={{
|
||||
width: 110,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'white',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderRight: 'none',
|
||||
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.23)'
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.87)'
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="match">{t('questions.searchMatch')}</MenuItem>
|
||||
<MenuItem value="notMatch">{t('questions.searchNotMatch')}</MenuItem>
|
||||
</Select>
|
||||
<TextField
|
||||
placeholder={t('questions.searchPlaceholder')}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={searchTerm}
|
||||
onChange={onSearchChange}
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" color="action" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
placeholder={t('questions.filterChunkNamePlaceholder')}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||
value={chunkNameFilter}
|
||||
onChange={onChunkNameFilterChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" color="action" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={sourceTypeFilter}
|
||||
onChange={onSourceTypeFilterChange}
|
||||
size="small"
|
||||
sx={{
|
||||
width: { xs: '100%', sm: 150 },
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'white',
|
||||
borderRadius: '8px',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.23)'
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.87)'
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
elevation: 2,
|
||||
sx: { mt: 1, borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">{t('questions.sourceTypeAll')}</MenuItem>
|
||||
<MenuItem value="text">{t('questions.sourceTypeText')}</MenuItem>
|
||||
<MenuItem value="image">{t('questions.sourceTypeImage')}</MenuItem>
|
||||
</Select>
|
||||
<Select
|
||||
value={answerFilter}
|
||||
onChange={onFilterChange}
|
||||
size="small"
|
||||
sx={{
|
||||
width: { xs: '100%', sm: 150 },
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'white',
|
||||
borderRadius: '8px',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.23)'
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.mode === 'dark' ? 'transparent' : 'rgba(0, 0, 0, 0.87)'
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
elevation: 2,
|
||||
sx: { mt: 1, borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">{t('questions.filterAll')}</MenuItem>
|
||||
<MenuItem value="answered">{t('questions.filterAnswered')}</MenuItem>
|
||||
<MenuItem value="unanswered">{t('questions.filterUnanswered')}</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user