387 lines
17 KiB
TypeScript
387 lines
17 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import {
|
||
Package,
|
||
Shield,
|
||
Loader2,
|
||
Users,
|
||
Server,
|
||
CheckCircle2,
|
||
} from "lucide-react";
|
||
import { useToast } from '@/components/ui/use-toast';
|
||
import { getDeployEnvironments, startDeployment } from './service';
|
||
import { ApplicationCard } from './components/ApplicationCard';
|
||
import type { DeployTeam, ApplicationConfig } from './types';
|
||
|
||
const LoadingState = () => (
|
||
<div className="flex-1 p-8">
|
||
<div className="flex flex-col items-center justify-center min-h-[400px]">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4"/>
|
||
<p className="text-sm text-muted-foreground">加载中,请稍候...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const Dashboard: React.FC = () => {
|
||
const { toast } = useToast();
|
||
const [loading, setLoading] = useState(true);
|
||
const [teams, setTeams] = useState<DeployTeam[]>([]);
|
||
const [currentTeamId, setCurrentTeamId] = useState<number | null>(null);
|
||
const [currentEnvId, setCurrentEnvId] = useState<number | null>(null);
|
||
const [deploying, setDeploying] = useState<Set<number>>(new Set());
|
||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// 加载部署环境数据
|
||
const loadData = React.useCallback(async (showLoading = false) => {
|
||
try {
|
||
if (showLoading) {
|
||
setLoading(true);
|
||
}
|
||
|
||
const response = await getDeployEnvironments();
|
||
|
||
if (response && response.teams) {
|
||
const prevTeamId = currentTeamId;
|
||
const prevEnvId = currentEnvId;
|
||
|
||
setTeams(response.teams);
|
||
|
||
if (isInitialLoad) {
|
||
// 首次加载:默认选中第一个团队和第一个环境
|
||
if (response.teams.length > 0) {
|
||
setCurrentTeamId(response.teams[0].teamId);
|
||
|
||
if (response.teams[0].environments.length > 0) {
|
||
setCurrentEnvId(response.teams[0].environments[0].environmentId);
|
||
}
|
||
}
|
||
setIsInitialLoad(false);
|
||
} else {
|
||
// 后续刷新:保持当前选中的团队和环境
|
||
// 如果之前选中的团队/环境仍然存在,保持选中
|
||
if (prevTeamId !== null) {
|
||
const teamExists = response.teams.some(t => t.teamId === prevTeamId);
|
||
if (teamExists) {
|
||
setCurrentTeamId(prevTeamId);
|
||
|
||
if (prevEnvId !== null) {
|
||
const team = response.teams.find(t => t.teamId === prevTeamId);
|
||
const envExists = team?.environments.some(e => e.environmentId === prevEnvId);
|
||
if (envExists) {
|
||
setCurrentEnvId(prevEnvId);
|
||
} else if (team && team.environments.length > 0) {
|
||
// 如果之前的环境不存在了,选择第一个环境
|
||
setCurrentEnvId(team.environments[0].environmentId);
|
||
}
|
||
}
|
||
} else if (response.teams.length > 0) {
|
||
// 如果之前的团队不存在了,选择第一个团队
|
||
setCurrentTeamId(response.teams[0].teamId);
|
||
if (response.teams[0].environments.length > 0) {
|
||
setCurrentEnvId(response.teams[0].environments[0].environmentId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查部署状态,如果应用不再是 RUNNING 状态,从 deploying 状态中移除
|
||
setDeploying((prevDeploying) => {
|
||
const newDeploying = new Set(prevDeploying);
|
||
let hasChanges = false;
|
||
|
||
// 遍历所有团队、环境、应用,检查部署状态
|
||
response.teams.forEach(team => {
|
||
team.environments.forEach(env => {
|
||
env.applications.forEach(app => {
|
||
// 如果应用在 deploying 状态中,但最新状态不是 RUNNING,则移除
|
||
if (newDeploying.has(app.teamApplicationId)) {
|
||
const latestStatus = app.deployStatistics?.latestStatus;
|
||
if (latestStatus && latestStatus !== 'RUNNING') {
|
||
newDeploying.delete(app.teamApplicationId);
|
||
hasChanges = true;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return hasChanges ? newDeploying : prevDeploying;
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载数据失败:', error);
|
||
// 只在首次加载失败时显示错误提示
|
||
if (isInitialLoad) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: '加载失败',
|
||
description: '无法加载部署环境数据,请稍后重试',
|
||
});
|
||
}
|
||
} finally {
|
||
if (showLoading) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, [currentTeamId, currentEnvId, isInitialLoad, toast]);
|
||
|
||
// 首次加载数据
|
||
useEffect(() => {
|
||
loadData(true);
|
||
}, []);
|
||
|
||
// 定时刷新数据(每5秒)
|
||
useEffect(() => {
|
||
// 只在非首次加载后开始轮询
|
||
if (isInitialLoad) return;
|
||
|
||
// 启动定时器的函数
|
||
const startPolling = () => {
|
||
// 清除旧的定时器
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current);
|
||
}
|
||
// 立即刷新一次
|
||
loadData(false);
|
||
// 设置新的定时器
|
||
intervalRef.current = setInterval(() => {
|
||
loadData(false);
|
||
}, 5000);
|
||
};
|
||
|
||
// 停止定时器的函数
|
||
const stopPolling = () => {
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current);
|
||
intervalRef.current = null;
|
||
}
|
||
};
|
||
|
||
// 页面可见性检测:当页面隐藏时暂停轮询,显示时恢复
|
||
const handleVisibilityChange = () => {
|
||
if (document.hidden) {
|
||
stopPolling();
|
||
} else {
|
||
startPolling();
|
||
}
|
||
};
|
||
|
||
// 启动轮询
|
||
startPolling();
|
||
|
||
// 监听页面可见性变化
|
||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||
|
||
return () => {
|
||
stopPolling();
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||
};
|
||
}, [isInitialLoad, loadData]);
|
||
|
||
// 切换团队时,自动选中第一个环境
|
||
const handleTeamChange = (teamId: string) => {
|
||
const newTeamId = Number(teamId);
|
||
setCurrentTeamId(newTeamId);
|
||
|
||
const team = teams.find(t => t.teamId === newTeamId);
|
||
if (team && team.environments.length > 0) {
|
||
setCurrentEnvId(team.environments[0].environmentId);
|
||
} else {
|
||
setCurrentEnvId(null);
|
||
}
|
||
};
|
||
|
||
// 处理部署
|
||
const handleDeploy = async (app: ApplicationConfig) => {
|
||
if (!currentEnv) return;
|
||
|
||
// 立即显示部署中状态
|
||
setDeploying((prev) => new Set(prev).add(app.teamApplicationId));
|
||
|
||
try {
|
||
await startDeployment(app.teamApplicationId);
|
||
|
||
toast({
|
||
title: currentEnv.requiresApproval ? '部署申请已提交' : '部署任务已创建',
|
||
description: currentEnv.requiresApproval
|
||
? '您的部署申请已提交,等待审批人审核'
|
||
: '部署任务已成功创建并开始执行',
|
||
});
|
||
|
||
// 接口成功后,保持部署中状态,等待自动刷新更新实际状态
|
||
// deploying 状态会在 loadData 中根据实际部署状态自动清除
|
||
} catch (error: any) {
|
||
// 接口失败时,立即清除部署中状态
|
||
setDeploying((prev) => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(app.teamApplicationId);
|
||
return newSet;
|
||
});
|
||
|
||
toast({
|
||
variant: 'destructive',
|
||
title: '操作失败',
|
||
description: error.response?.data?.message || '部署失败,请稍后重试',
|
||
});
|
||
}
|
||
};
|
||
|
||
// 获取当前团队和环境
|
||
const currentTeam = teams.find(t => t.teamId === currentTeamId);
|
||
const currentEnv = currentTeam?.environments.find(e => e.environmentId === currentEnvId);
|
||
const currentApps = currentEnv?.applications || [];
|
||
|
||
if (loading) {
|
||
return <LoadingState/>;
|
||
}
|
||
|
||
if (teams.length === 0) {
|
||
return (
|
||
<div className="flex-1 p-8">
|
||
<h2 className="text-3xl font-bold tracking-tight mb-6">部署管理</h2>
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||
<p className="text-sm text-muted-foreground">您还没有加入任何团队</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex-1 p-8 space-y-6">
|
||
{/* 页面标题和团队选择器 */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-3xl font-bold tracking-tight">部署管理</h2>
|
||
<p className="text-muted-foreground mt-1">
|
||
管理和执行应用部署任务
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Users className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm text-muted-foreground">当前团队:</span>
|
||
</div>
|
||
<Select value={currentTeamId?.toString()} onValueChange={handleTeamChange}>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue placeholder="选择团队" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{teams.map((team) => (
|
||
<SelectItem key={team.teamId} value={team.teamId.toString()}>
|
||
{team.teamName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 当前团队信息 */}
|
||
{currentTeam && (
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<span className="font-medium text-foreground">{currentTeam.teamName}</span>
|
||
{currentTeam.teamRole && (
|
||
<>
|
||
<span>·</span>
|
||
<span>{currentTeam.teamRole}</span>
|
||
</>
|
||
)}
|
||
{currentTeam.description && (
|
||
<>
|
||
<span>·</span>
|
||
<span>{currentTeam.description}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 环境切换和应用列表 */}
|
||
{currentTeam && (
|
||
currentTeam.environments.length === 0 ? (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||
<Server className="h-16 w-16 text-muted-foreground mb-4" />
|
||
<h3 className="text-lg font-semibold mb-2">暂无部署环境</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
当前团队「{currentTeam.teamName}」还没有配置任何部署环境
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<Card>
|
||
<CardHeader className="border-b">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<CardTitle>选择部署环境</CardTitle>
|
||
<Select value={currentEnvId?.toString()} onValueChange={(value) => setCurrentEnvId(Number(value))}>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue placeholder="选择环境" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{currentTeam.environments.map((env) => (
|
||
<SelectItem key={env.environmentId} value={env.environmentId.toString()}>
|
||
<div className="flex items-center gap-2">
|
||
{env.requiresApproval ? (
|
||
<Shield className="h-3 w-3 text-amber-600" />
|
||
) : (
|
||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||
)}
|
||
{env.environmentName}
|
||
<span className="text-xs text-muted-foreground">({env.applications.length})</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-h-[20px]">
|
||
{currentEnv && currentEnv.requiresApproval && currentEnv.approvers.length > 0 ? (
|
||
<>
|
||
<Users className="h-4 w-4" />
|
||
<span>审批人: {currentEnv.approvers.map((a) => a.realName).join('、')}</span>
|
||
</>
|
||
) : (
|
||
<span className="opacity-0">占位</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-6">
|
||
|
||
{/* 应用列表 */}
|
||
{currentApps.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-16">
|
||
<Package className="h-16 w-16 text-muted-foreground mb-4" />
|
||
<h3 className="text-lg font-semibold mb-2">暂无可部署应用</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
环境「{currentEnv?.environmentName}」暂未配置任何应用
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-6 gap-3">
|
||
{currentApps.map((app) => (
|
||
<ApplicationCard
|
||
key={app.teamApplicationId}
|
||
app={app}
|
||
environment={currentEnv!}
|
||
onDeploy={handleDeploy}
|
||
isDeploying={deploying.has(app.teamApplicationId)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Dashboard;
|