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,173 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
useTheme,
CircularProgress,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
export default function CreateProjectDialog({ open, onClose }) {
const { t } = useTranslation();
const theme = useTheme();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState([]);
const [formData, setFormData] = useState({
name: '',
description: '',
reuseConfigFrom: ''
});
const [error, setError] = useState(null);
// 获取项目列表
useEffect(() => {
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects');
if (response.ok) {
const data = await response.json();
setProjects(data);
}
} catch (error) {
console.error('获取项目列表失败:', error);
}
};
fetchProjects();
}, []);
const handleChange = e => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async e => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (!response.ok) {
throw new Error(t('projects.createFailed'));
}
const data = await response.json();
router.push(`/projects/${data.id}/settings?tab=model`);
} catch (err) {
console.error(t('projects.createError'), err);
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: '16px',
background: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.9)' : 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(8px)'
}
}}
>
<DialogTitle>
<Typography variant="h5" fontWeight="bold">
{t('projects.createNew')}
</Typography>
</DialogTitle>
<form onSubmit={handleSubmit}>
<DialogContent>
<Box sx={{ mb: 3 }}>
<TextField
name="name"
label={t('projects.name')}
fullWidth
required
value={formData.name}
onChange={handleChange}
sx={{ mb: 2 }}
/>
<TextField
name="description"
label={t('projects.description')}
fullWidth
multiline
rows={4}
value={formData.description}
onChange={handleChange}
/>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel id="reuse-config-label">{t('projects.reuseConfig')}</InputLabel>
<Select
labelId="reuse-config-label"
name="reuseConfigFrom"
value={formData.reuseConfigFrom}
onChange={handleChange}
label={t('projects.reuseConfig')}
>
<MenuItem value="">{t('projects.noReuse')}</MenuItem>
{projects.map(project => (
<MenuItem key={project.id} value={project.id}>
{project.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{error && (
<Typography color="error" variant="body2" sx={{ mb: 2 }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
type="submit"
variant="contained"
disabled={loading || !formData.name}
sx={{
background: theme.palette.gradient.primary,
'&:hover': {
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)'
}
}}
>
{loading ? <CircularProgress size={24} /> : t('home.createProject')}
</Button>
</DialogActions>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { Box, Container, Typography, Button, useMediaQuery } from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import SearchIcon from '@mui/icons-material/Search';
import { styles } from '@/styles/home';
import { useTheme } from '@mui/material';
import { motion } from 'framer-motion';
import ParticleBackground from './ParticleBackground';
import { useTranslation } from 'react-i18next';
export default function HeroSection({ onCreateProject }) {
const { t } = useTranslation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
return (
<Box sx={{ ...styles.heroSection, ...styles.heroBackground(theme) }}>
{/* 添加粒子背景 */}
<ParticleBackground />
<Box sx={styles.decorativeCircle} />
<Box sx={styles.decorativeCircleSecond} />
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 1 }}>
<Box
sx={{
textAlign: 'center',
maxWidth: '800px',
mx: 'auto',
py: { xs: 5, md: 8 }
}}
component={motion.div}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<Typography
variant={isMobile ? 'h3' : 'h1'}
component="h1"
fontWeight="bold"
sx={{
...styles.gradientTitle(theme),
letterSpacing: '-1px',
mb: 3,
textShadow: theme.palette.mode === 'dark' ? '0 0 30px rgba(139, 92, 246, 0.3)' : 'none'
}}
>
{t('home.title')}
</Typography>
<Typography
variant={isMobile ? 'body1' : 'h5'}
component={motion.p}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.8 }}
color="text.secondary"
paragraph
sx={{
maxWidth: '650px',
mx: 'auto',
lineHeight: 1.8,
opacity: 0.9,
fontSize: { xs: '1rem', md: '1.2rem' },
fontWeight: 400,
mb: 4
}}
>
{t('home.subtitle')}
</Typography>
<Box
component={motion.div}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
sx={{
mt: 6,
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'center',
gap: { xs: 2, sm: 3 }
}}
>
<Button
variant="contained"
size="large"
onClick={onCreateProject}
startIcon={<AddCircleOutlineIcon />}
sx={{
...styles.createButton(theme),
fontWeight: 600,
transition: 'all 0.3s ease',
transform: 'translateY(0)',
px: 4,
py: 1.5,
borderRadius: '12px',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)'
}
}}
>
{t('home.createProject')}
</Button>
<Button
variant="contained"
size="large"
onClick={() => {
window.location.href = '/dataset-square';
}}
startIcon={<SearchIcon />}
sx={{
...styles.createButton(theme),
fontWeight: 600,
transition: 'all 0.3s ease',
transform: 'translateY(0)',
px: 4,
py: 1.5,
borderRadius: '12px',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)'
}
}}
>
{t('home.searchDataset')}
</Button>
</Box>
</Box>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,300 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
CircularProgress,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Alert,
Paper,
useTheme,
Tooltip
} from '@mui/material';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next';
/**
* 项目迁移对话框组件
* @param {Object} props - 组件属性
* @param {boolean} props.open - 对话框是否打开
* @param {Function} props.onClose - 关闭对话框的回调函数
* @param {Array<string>} props.projectIds - 需要迁移的项目ID列表
*/
export default function MigrationDialog({ open, onClose, projectIds = [] }) {
const { t } = useTranslation();
const theme = useTheme();
const [migrating, setMigrating] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(null);
const [migratedCount, setMigratedCount] = useState(0);
const [taskId, setTaskId] = useState(null);
const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState('');
const [processingIds, setProcessingIds] = useState([]);
// 打开项目目录
const handleOpenDirectory = async projectId => {
try {
setProcessingIds(prev => [...prev, projectId]);
const response = await fetch('/api/projects/open-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ projectId })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t('migration.openDirectoryFailed'));
}
// 成功打开目录,不需要特别处理
} catch (err) {
console.error('打开目录错误:', err);
setError(err.message);
} finally {
setProcessingIds(prev => prev.filter(id => id !== projectId));
}
};
// 删除项目目录
const handleDeleteDirectory = async projectId => {
try {
if (!window.confirm(t('migration.confirmDelete'))) {
return;
}
setProcessingIds(prev => [...prev, projectId]);
const response = await fetch('/api/projects/delete-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ projectId })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t('migration.deleteDirectoryFailed'));
}
// 从列表中移除已删除的项目
const updatedProjectIds = projectIds.filter(id => id !== projectId);
// 这里我们不能直接修改 projectIds因为它是从父组件传入的
// 但我们可以通知用户界面刷新
window.location.reload();
} catch (err) {
console.error('删除目录错误:', err);
setError(err.message);
} finally {
setProcessingIds(prev => prev.filter(id => id !== projectId));
}
};
// 处理迁移操作
const handleMigration = async () => {
try {
setMigrating(true);
setError(null);
setSuccess(false);
setProgress(0);
setStatusText(t('migration.starting'));
// 调用异步迁移接口启动迁移任务
const response = await fetch('/api/projects/migrate', {
method: 'POST'
});
if (!response.ok) {
throw new Error(t('migration.failed'));
}
const { success, taskId: newTaskId } = await response.json();
if (!success || !newTaskId) {
throw new Error(t('migration.startFailed'));
}
// 保存任务ID
setTaskId(newTaskId);
setStatusText(t('migration.processing'));
// 开始轮询任务状态
await pollMigrationStatus(newTaskId);
} catch (err) {
console.error('迁移错误:', err);
setError(err.message);
setMigrating(false);
}
};
// 轮询迁移任务状态
const pollMigrationStatus = async id => {
try {
// 定义轮询间隔(毫秒)
const pollInterval = 1000;
// 发送请求获取任务状态
const response = await fetch(`/api/projects/migrate?taskId=${id}`);
if (!response.ok) {
throw new Error(t('migration.statusFailed'));
}
const { success, task } = await response.json();
if (!success || !task) {
throw new Error(t('migration.taskNotFound'));
}
// 更新进度
setProgress(task.progress || 0);
// 根据任务状态更新UI
if (task.status === 'completed') {
// 任务完成
setMigratedCount(task.completed);
setSuccess(true);
setMigrating(false);
setStatusText(t('migration.completed'));
// 迁移成功后,延迟关闭对话框并刷新页面
setTimeout(() => {
onClose();
window.location.reload();
}, 2000);
} else if (task.status === 'failed') {
// 任务失败
throw new Error(task.error || t('migration.failed'));
} else {
// 任务仍在进行中,继续轮询
setTimeout(() => pollMigrationStatus(id), pollInterval);
// 更新状态文本
if (task.total > 0) {
setStatusText(
t('migration.progressStatus', {
completed: task.completed || 0,
total: task.total
})
);
}
}
} catch (err) {
console.error('获取迁移状态错误:', err);
setError(err.message);
setMigrating(false);
}
};
return (
<Dialog open={open} onClose={migrating ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5
}}
>
<WarningAmberIcon color="warning" />
<Typography variant="h6">{t('migration.title')}</Typography>
</DialogTitle>
<DialogContent>
{success ? (
<Alert severity="success" sx={{ mb: 2 }}>
{t('migration.success', { count: migratedCount })}
</Alert>
) : error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
) : null}
<Typography variant="body1" sx={{ mb: 2 }}>
{t('migration.description')}
</Typography>
{projectIds.length > 0 && (
<Box sx={{ mt: 2, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
{t('migration.projectsList')}:
</Typography>
<Paper variant="outlined" sx={{ maxHeight: 180, overflow: 'auto' }}>
<List dense>
{projectIds.map(id => (
<ListItem key={id}>
<ListItemText primary={id} />
<ListItemSecondaryAction>
<Tooltip title={t('migration.openDirectory')}>
<IconButton
edge="end"
aria-label="open"
onClick={() => handleOpenDirectory(id)}
disabled={processingIds.includes(id)}
size="small"
>
<FolderOpenIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('migration.deleteDirectory')}>
<IconButton
edge="end"
aria-label="delete"
onClick={() => handleDeleteDirectory(id)}
disabled={processingIds.includes(id)}
size="small"
sx={{ ml: 1, color: 'error.main' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</Paper>
</Box>
)}
{migrating && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 3, gap: 1.5 }}>
<CircularProgress variant={progress > 0 ? 'determinate' : 'indeterminate'} value={progress} />
<Typography variant="body2" color="text.secondary">
{statusText || t('migration.migrating')}
</Typography>
{progress > 0 && (
<Typography variant="body2" color="text.secondary">
{progress}%
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={migrating}>
{t('common.cancel')}
</Button>
<Button onClick={handleMigration} variant="contained" color="primary" disabled={migrating || success}>
{migrating ? t('migration.migrating') : t('migration.migrate')}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,251 @@
'use client';
import { useEffect, useRef } from 'react';
import { useTheme } from '@mui/material';
export default function ParticleBackground() {
const canvasRef = useRef(null);
const theme = useTheme();
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
let animationFrameId;
let particles = [];
let mousePosition = { x: 0, y: 0 };
let hoverRadius = 150; // 增加鼠标影响范围
let mouseSpeed = { x: 0, y: 0 }; // 跟踪鼠标速度
let lastMousePosition = { x: 0, y: 0 }; // 上一帧鼠标位置
// 设置画布大小为窗口大小
const handleResize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initParticles();
};
// 跟踪鼠标位置和速度
const handleMouseMove = event => {
// 计算鼠标速度
mouseSpeed.x = event.clientX - mousePosition.x;
mouseSpeed.y = event.clientY - mousePosition.y;
// 更新鼠标位置
lastMousePosition.x = mousePosition.x;
lastMousePosition.y = mousePosition.y;
mousePosition.x = event.clientX;
mousePosition.y = event.clientY;
};
// 触摸设备支持
const handleTouchMove = event => {
if (event.touches.length > 0) {
// 计算触摸速度
mouseSpeed.x = event.touches[0].clientX - mousePosition.x;
mouseSpeed.y = event.touches[0].clientY - mousePosition.y;
// 更新触摸位置
lastMousePosition.x = mousePosition.x;
lastMousePosition.y = mousePosition.y;
mousePosition.x = event.touches[0].clientX;
mousePosition.y = event.touches[0].clientY;
}
};
// 生成随机颜色
const getRandomColor = () => {
// 主题色调
const colors =
theme.palette.mode === 'dark'
? [
'rgba(255, 255, 255, 0.5)', // 白色
'rgba(100, 181, 246, 0.5)', // 蓝色
'rgba(156, 39, 176, 0.4)', // 紫色
'rgba(121, 134, 203, 0.5)' // 靛蓝色
]
: [
'rgba(42, 92, 170, 0.5)', // 主蓝色
'rgba(66, 165, 245, 0.4)', // 浅蓝色
'rgba(94, 53, 177, 0.3)', // 深紫色
'rgba(3, 169, 244, 0.4)' // 天蓝色
];
return colors[Math.floor(Math.random() * colors.length)];
};
// 初始化粒子
const initParticles = () => {
particles = [];
// 增加粒子数量,但保持性能平衡
const particleCount = Math.min(Math.floor(window.innerWidth / 8), 150);
for (let i = 0; i < particleCount; i++) {
// 创建不同大小和速度的粒子
const size = Math.random();
const speedFactor = Math.max(0.1, size); // 较大的粒子移动较慢
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
// 粒子大小更加多样化
radius: size * 3 + 0.5,
// 使用随机颜色
color: getRandomColor(),
// 添加发光效果
glow: Math.random() * 10 + 5,
// 调整速度范围,使运动更加自然
speedX: (Math.random() * 0.6 - 0.3) * speedFactor,
speedY: (Math.random() * 0.6 - 0.3) * speedFactor,
originalSpeedX: (Math.random() * 0.6 - 0.3) * speedFactor,
originalSpeedY: (Math.random() * 0.6 - 0.3) * speedFactor,
// 添加脉动效果
pulseSpeed: Math.random() * 0.02 + 0.01,
pulseDirection: Math.random() > 0.5 ? 1 : -1,
pulseAmount: 0,
// 粒子透明度
opacity: Math.random() * 0.5 + 0.5
});
}
};
// 绘制粒子
const drawParticles = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 计算鼠标速度衰减
mouseSpeed.x *= 0.95;
mouseSpeed.y *= 0.95;
// 绘制粒子之间的连线
drawLines();
particles.forEach(particle => {
// 计算粒子与鼠标的距离
const dx = mousePosition.x - particle.x;
const dy = mousePosition.y - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 脉动效果
particle.pulseAmount += particle.pulseSpeed * particle.pulseDirection;
if (Math.abs(particle.pulseAmount) > 0.5) {
particle.pulseDirection *= -1;
}
// 如果粒子在鼠标影响范围内,调整其速度
if (distance < hoverRadius) {
const angle = Math.atan2(dy, dx);
const force = (hoverRadius - distance) / hoverRadius;
const mouseFactor = 3; // 增强鼠标影响力度
// 粒子远离鼠标,并受鼠标速度影响
particle.speedX = -Math.cos(angle) * force * mouseFactor + particle.originalSpeedX + mouseSpeed.x * 0.05;
particle.speedY = -Math.sin(angle) * force * mouseFactor + particle.originalSpeedY + mouseSpeed.y * 0.05;
} else {
// 逐渐恢复原始速度
particle.speedX = particle.speedX * 0.95 + particle.originalSpeedX * 0.05;
particle.speedY = particle.speedY * 0.95 + particle.originalSpeedY * 0.05;
}
// 更新粒子位置
particle.x += particle.speedX;
particle.y += particle.speedY;
// 边界检查
if (particle.x < 0) particle.x = canvas.width;
if (particle.x > canvas.width) particle.x = 0;
if (particle.y < 0) particle.y = canvas.height;
if (particle.y > canvas.height) particle.y = 0;
// 应用脉动效果到粒子大小
const currentRadius = particle.radius * (1 + particle.pulseAmount * 0.2);
// 绘制发光效果
const gradient = ctx.createRadialGradient(particle.x, particle.y, 0, particle.x, particle.y, particle.glow);
gradient.addColorStop(0, particle.color);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
// 绘制粒子
ctx.beginPath();
ctx.arc(particle.x, particle.y, currentRadius, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.fill();
// 添加发光效果
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.glow, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.globalAlpha = 0.3 * particle.opacity;
ctx.fill();
ctx.globalAlpha = 1.0;
});
animationFrameId = requestAnimationFrame(drawParticles);
};
// 绘制粒子之间的连线
const drawLines = () => {
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 增加连线的最大距离
const maxDistance = 120;
if (distance < maxDistance) {
// 只在粒子距离小于maxDistance时绘制连线
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
// 根据距离设置线条透明度
const opacity = 1 - distance / maxDistance;
// 根据主题设置线条颜色
const lineColor =
theme.palette.mode === 'dark'
? `rgba(255, 255, 255, ${opacity * 0.2})`
: `rgba(42, 92, 170, ${opacity * 0.2})`;
ctx.strokeStyle = lineColor;
ctx.lineWidth = opacity * 1.5; // 根据距离调整线宽
ctx.stroke();
}
}
}
};
// 初始化
handleResize();
window.addEventListener('resize', handleResize);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove);
// 开始动画
drawParticles();
// 清理函数
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
cancelAnimationFrame(animationFrameId);
};
}, [theme.palette.mode]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none', // 确保不会干扰下方元素的交互
zIndex: 0
}}
/>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import {
Card,
Box,
CardActionArea,
CardContent,
Typography,
Avatar,
Divider,
IconButton,
Menu,
MenuItem,
ListItemIcon
} from '@mui/material';
import Link from 'next/link';
import { styles } from '@/styles/home';
import { useTheme, alpha } from '@mui/material/styles';
import DataObjectIcon from '@mui/icons-material/DataObject';
import DeleteIcon from '@mui/icons-material/Delete';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import TokenIcon from '@mui/icons-material/Token';
import AssessmentIcon from '@mui/icons-material/Assessment';
import QuizIcon from '@mui/icons-material/Quiz';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
/**
* 统计项组件
*/
const StatItem = ({ icon: Icon, count, label, color, isToken }) => {
const theme = useTheme();
// 格式化数字
const displayCount = isToken ? (count || 0).toLocaleString() : count || 0;
return (
<Box sx={styles.statItem(theme)}>
<Box sx={styles.statIconBox(theme, color)}>
<Icon sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Typography variant="subtitle2" fontWeight="700" sx={{ lineHeight: 1 }}>
{displayCount}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ fontSize: '0.7rem' }}>
{label}
</Typography>
</Box>
</Box>
);
};
/**
* 项目卡片组件
* @param {Object} props - 组件属性
* @param {Object} props.project - 项目数据
* @param {Function} props.onDeleteClick - 删除按钮点击事件处理函数
*/
export default function ProjectCard({ project, onDeleteClick }) {
const { t } = useTranslation();
const theme = useTheme();
const [processingId, setProcessingId] = useState(false);
// 菜单状态
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
// 打开项目目录
const handleOpenDirectory = async event => {
event.stopPropagation();
event.preventDefault();
if (processingId) return;
try {
setProcessingId(true);
const response = await fetch('/api/projects/open-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ projectId: project.id })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t('migration.openDirectoryFailed'));
}
// 成功打开目录,不需要特别处理
} catch (error) {
console.error('打开目录错误:', error);
alert(error.message);
} finally {
setProcessingId(false);
}
};
// 处理菜单打开
const handleMenuClick = event => {
event.stopPropagation();
event.preventDefault();
setAnchorEl(event.currentTarget);
};
// 处理菜单关闭
const handleMenuClose = event => {
if (event) {
event.stopPropagation();
event.preventDefault();
}
setAnchorEl(null);
};
// 处理打开目录点击
const handleOpenDirectoryClick = event => {
handleMenuClose(event);
handleOpenDirectory(event);
};
// 处理删除点击
const handleDeleteClick = event => {
handleMenuClose(event);
onDeleteClick(event, project);
};
return (
<Card sx={styles.projectCard(theme)}>
<Link
href={`/projects/${project.id}`}
passHref
style={{ textDecoration: 'none', color: 'inherit', height: '100%' }}
>
<CardActionArea component="div" sx={{ height: '100%' }}>
<CardContent sx={styles.projectCardContent}>
{/* 头部Avatar + Title + Menu */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', overflow: 'hidden', flex: 1 }}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
width: 40,
height: 40,
fontSize: '1.1rem',
fontWeight: 'bold',
borderRadius: '10px'
}}
>
{project.name.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ overflow: 'hidden', flex: 1 }}>
<Typography variant="h6" sx={styles.projectTitle}>
{project.name}
</Typography>
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.7rem' }}>
ID: {project.id}
</Typography>
</Box>
</Box>
<IconButton
size="small"
onClick={handleMenuClick}
sx={{
color: 'text.secondary',
padding: '4px',
'&:hover': { color: 'primary.main', bgcolor: alpha(theme.palette.primary.main, 0.1) }
}}
>
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
{/* 描述 */}
<Typography variant="body2" sx={styles.projectDescription}>
{project.description || t('projects.noDescription', { defaultValue: '暂无描述' })}
</Typography>
{/* 统计数据 */}
<Box sx={styles.statsContainer}>
<StatItem
icon={QuizIcon}
count={project._count.Questions}
label={t('projects.questions')}
color="primary"
/>
<StatItem
icon={DataObjectIcon}
count={(project._count.ImageDatasets || 0) + (project._count.Datasets || 0)}
label={t('projects.datasets')}
color="secondary"
/>
<StatItem
icon={AssessmentIcon}
count={project._count.EvalDatasets}
label={t('projects.evalDatasets')}
color="info"
/>
<StatItem
icon={TokenIcon}
count={project.totalTokens}
label={t('projects.tokens')}
color="success"
isToken
/>
</Box>
</CardContent>
</CardActionArea>
</Link>
{/* 操作菜单 */}
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleMenuClose}
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{
elevation: 3,
sx: {
borderRadius: '12px',
minWidth: 160,
mt: 0.5
}
}}
>
<MenuItem onClick={handleOpenDirectoryClick}>
<ListItemIcon>
<FolderOpenIcon fontSize="small" />
</ListItemIcon>
<Typography variant="body2">{t('projects.openDirectory')}</Typography>
</MenuItem>
<Divider sx={{ my: 0.5, opacity: 0.5 }} />
<MenuItem onClick={handleDeleteClick} sx={{ color: 'error.main' }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<Typography variant="body2">{t('common.delete')}</Typography>
</MenuItem>
</Menu>
</Card>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import {
Grid,
Paper,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Typography
} from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import ProjectCard from './ProjectCard';
export default function ProjectList({ projects, onCreateProject }) {
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState(null);
const [loading, setLoading] = useState(false);
// 打开删除确认对话框
const handleOpenDeleteDialog = (event, project) => {
setProjectToDelete(project);
setDeleteDialogOpen(true);
};
// 关闭删除确认对话框
const handleCloseDeleteDialog = () => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
};
// 删除项目
const handleDeleteProject = async () => {
if (!projectToDelete) return;
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectToDelete.id}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || t('projects.deleteFailed'));
}
// 刷新页面以更新项目列表
window.location.reload();
} catch (error) {
console.error('删除项目失败:', error);
alert(error.message || t('projects.deleteFailed'));
} finally {
setLoading(false);
handleCloseDeleteDialog();
}
};
return (
<>
<Grid container spacing={3}>
{projects.length === 0 ? (
<Grid item xs={12}>
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
{t('projects.noProjects')}
</Typography>
<Button variant="contained" onClick={onCreateProject} startIcon={<AddCircleOutlineIcon />} sx={{ mt: 2 }}>
{t('projects.createFirst')}
</Button>
</Paper>
</Grid>
) : (
projects.map(project => (
<Grid item xs={12} sm={6} md={4} key={project.id}>
<ProjectCard project={project} onDeleteClick={handleOpenDeleteDialog} />
</Grid>
))
)}
</Grid>
{/* 删除确认对话框 */}
<Dialog
open={deleteDialogOpen}
onClose={handleCloseDeleteDialog}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">{t('projects.deleteConfirmTitle')}</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
{projectToDelete && (
<>
{t('projects.deleteConfirm')}
<br />
<Typography component="span" fontWeight="bold" sx={{ mt: 1, display: 'inline-block' }}>
{projectToDelete.name}
</Typography>
</>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDeleteDialog} disabled={loading}>
{t('common.cancel')}
</Button>
<Button onClick={handleDeleteProject} color="error" variant="contained" disabled={loading}>
{loading ? t('common.deleting') : t('common.delete')}
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { Paper, Grid, Box, Typography, useMediaQuery, Avatar } from '@mui/material';
import { styles } from '@/styles/home';
import { useTheme } from '@mui/material';
import { motion } from 'framer-motion';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import StorageIcon from '@mui/icons-material/Storage';
import MemoryIcon from '@mui/icons-material/Memory';
// 默认模型列表
const mockModels = [
{ id: 'deepseek-r1', provider: 'Ollama', name: 'DeepSeek-R1' },
{ id: 'gpt-3.5-turbo-openai', provider: 'OpenAI', name: 'gpt-3.5-turbo' },
{ id: 'gpt-3.5-turbo-guiji', provider: 'Guiji', name: 'gpt-3.5-turbo' },
{ id: 'glm-4-flash', provider: 'Zhipu AI', name: 'GLM-4-Flash' }
];
export default function StatsCard({ projects }) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// 统计卡片数据
const statsItems = [
{
value: projects.length,
label: t('stats.ongoingProjects'),
color: 'primary',
icon: <FolderOpenIcon />
},
{
value: projects.reduce((sum, project) => sum + (project.questionsCount || 0), 0),
label: t('stats.questionCount'),
color: 'secondary',
icon: <QuestionAnswerIcon />
},
{
value: projects.reduce((sum, project) => sum + (project.datasetsCount || 0), 0),
label: t('stats.generatedDatasets'),
color: 'success',
icon: <StorageIcon />
},
{
value: mockModels.length,
label: t('stats.supportedModels'),
color: 'warning',
icon: <MemoryIcon />
}
];
return (
<Paper
elevation={0}
sx={styles.statsCard(theme)}
component={motion.div}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Grid container spacing={3}>
{statsItems.map((item, index) => (
<Grid item xs={12} sm={6} md={3} key={index}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 2,
borderRadius: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: `0 10px 20px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.05)'}`
}
}}
component={motion.div}
whileHover={{ scale: 1.03 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<Avatar
sx={{
width: 56,
height: 56,
mb: 2,
bgcolor: theme.palette[item.color].main,
color: '#fff',
boxShadow: `0 4px 12px ${theme.palette[item.color].main}40`
}}
>
{item.icon}
</Avatar>
<Typography
color={item.color + '.main'}
variant={isMobile ? 'h3' : 'h2'}
fontWeight="bold"
sx={{
mb: 0.5,
background: `linear-gradient(135deg, ${theme.palette[item.color].main} 0%, ${theme.palette[item.color].light} 100%)`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
textFillColor: 'transparent'
}}
>
{item.value}
</Typography>
<Typography variant="subtitle1" color="text.secondary" fontWeight="500" sx={{ opacity: 0.8 }}>
{item.label}
</Typography>
</Box>
</Grid>
))}
</Grid>
</Paper>
);
}