first-update

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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