301 lines
9.1 KiB
JavaScript
301 lines
9.1 KiB
JavaScript
'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>
|
||
);
|
||
}
|