重构前端逻辑
This commit is contained in:
parent
a964dac6b0
commit
a682d5718b
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { message, Modal } from 'antd';
|
||||
import type { Page } from '@/types/base/page';
|
||||
import type { TablePaginationConfig } from 'antd/es/table';
|
||||
@ -60,16 +60,30 @@ export function useTableData<
|
||||
loading: false
|
||||
});
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async (params?: Partial<Q>) => {
|
||||
// 使用 ref 存储当前分页参数,避免循环依赖
|
||||
const paginationRef = useRef({
|
||||
current: 1,
|
||||
pageSize: config.defaultPageSize || 10
|
||||
});
|
||||
|
||||
// 同步 ref 和 state
|
||||
useEffect(() => {
|
||||
paginationRef.current = {
|
||||
current: state.pagination.current || 1,
|
||||
pageSize: state.pagination.pageSize || 10
|
||||
};
|
||||
}, [state.pagination.current, state.pagination.pageSize]);
|
||||
|
||||
// 加载数据 - 修复:使用 ref 避免依赖 state
|
||||
const loadData = useCallback(async (params?: Partial<Q> & { pageNum?: number; pageSize?: number }) => {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const pageData = await service.list({
|
||||
...defaultParams,
|
||||
...params,
|
||||
pageNum: state.pagination.current,
|
||||
pageSize: state.pagination.pageSize
|
||||
});
|
||||
pageNum: params?.pageNum ?? paginationRef.current.current,
|
||||
pageSize: params?.pageSize ?? paginationRef.current.pageSize
|
||||
} as Q & { pageNum?: number; pageSize?: number });
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
@ -80,11 +94,12 @@ export function useTableData<
|
||||
},
|
||||
loading: false
|
||||
}));
|
||||
return pageData;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
throw error;
|
||||
}
|
||||
}, [service, defaultParams, state.pagination]);
|
||||
}, [service, defaultParams]);
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (
|
||||
|
||||
127
frontend/src/pages/Dashboard/components/EnvironmentTabs.tsx
Normal file
127
frontend/src/pages/Dashboard/components/EnvironmentTabs.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Package, Shield, CheckCircle2 } from "lucide-react";
|
||||
import { ApplicationCard } from './ApplicationCard';
|
||||
import type { DeployTeam, DeployEnvironment, ApplicationConfig } from '../types';
|
||||
|
||||
interface EnvironmentTabsProps {
|
||||
team: DeployTeam;
|
||||
currentEnvId: number | null;
|
||||
deploying: Set<number>;
|
||||
onEnvChange: (envId: number) => void;
|
||||
onDeploy: (app: ApplicationConfig, remark: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境标签页组件
|
||||
* 显示环境切换标签和应用列表
|
||||
*/
|
||||
export const EnvironmentTabs: React.FC<EnvironmentTabsProps> = React.memo(({
|
||||
team,
|
||||
currentEnvId,
|
||||
deploying,
|
||||
onEnvChange,
|
||||
onDeploy
|
||||
}) => {
|
||||
const currentEnv = team.environments.find(e => e.environmentId === currentEnvId);
|
||||
|
||||
return (
|
||||
<Tabs value={currentEnvId?.toString()} onValueChange={(value) => onEnvChange(Number(value))}>
|
||||
{/* 现代化 TAB 头部 */}
|
||||
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
||||
<div className="flex items-center justify-between px-6 pt-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-1 bg-primary rounded-full" />
|
||||
<h3 className="text-lg font-semibold">部署环境</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-h-[20px]">
|
||||
{currentEnv && currentEnv.requiresApproval && currentEnv.approvers.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800">
|
||||
<Shield className="h-3.5 w-3.5 text-amber-600" />
|
||||
<span className="text-amber-900 dark:text-amber-200 font-medium">
|
||||
需审批: {currentEnv.approvers.map((a) => a.realName).join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-3">
|
||||
<div className="inline-flex h-11 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground gap-1">
|
||||
{team.environments.map((env) => (
|
||||
<button
|
||||
key={env.environmentId}
|
||||
onClick={() => onEnvChange(env.environmentId)}
|
||||
className={`
|
||||
inline-flex items-center justify-center whitespace-nowrap rounded-md px-4 py-2
|
||||
text-sm font-medium ring-offset-background transition-all
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
${currentEnvId === env.environmentId
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-background/50 hover:text-foreground'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{env.requiresApproval ? (
|
||||
<Shield className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-amber-600' : 'text-amber-500/60'}`} />
|
||||
) : (
|
||||
<CheckCircle2 className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-green-600' : 'text-green-500/60'}`} />
|
||||
)}
|
||||
<span>{env.environmentName}</span>
|
||||
<span className={`
|
||||
ml-1 rounded-full px-2 py-0.5 text-xs font-semibold
|
||||
${currentEnvId === env.environmentId
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
}
|
||||
`}>
|
||||
{env.applications.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{team.environments.map((env) => (
|
||||
<TabsContent key={env.environmentId} value={env.environmentId.toString()} className="mt-0 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<div className="p-6">
|
||||
{env.applications.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="rounded-full bg-muted p-6 mb-4">
|
||||
<Package className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">暂无可部署应用</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
环境「{env.environmentName}」暂未配置任何应用
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{env.applications.map((app) => (
|
||||
<ApplicationCard
|
||||
key={app.teamApplicationId}
|
||||
app={app}
|
||||
environment={env}
|
||||
teamId={team.teamId}
|
||||
onDeploy={onDeploy}
|
||||
isDeploying={deploying.has(app.teamApplicationId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
});
|
||||
|
||||
EnvironmentTabs.displayName = 'EnvironmentTabs';
|
||||
66
frontend/src/pages/Dashboard/components/TeamSelector.tsx
Normal file
66
frontend/src/pages/Dashboard/components/TeamSelector.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Users, ClipboardCheck } from "lucide-react";
|
||||
import type { DeployTeam } from '../types';
|
||||
|
||||
interface TeamSelectorProps {
|
||||
teams: DeployTeam[];
|
||||
currentTeamId: number | null;
|
||||
pendingApprovalCount: number;
|
||||
onTeamChange: (teamId: string) => void;
|
||||
onApprovalClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队选择器组件
|
||||
* 负责显示团队选择下拉框和待审批按钮
|
||||
*/
|
||||
export const TeamSelector: React.FC<TeamSelectorProps> = React.memo(({
|
||||
teams,
|
||||
currentTeamId,
|
||||
pendingApprovalCount,
|
||||
onTeamChange,
|
||||
onApprovalClick
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 待审批按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-orange-500 text-orange-600 hover:bg-orange-50 hover:text-orange-700 hover:border-orange-600"
|
||||
onClick={onApprovalClick}
|
||||
>
|
||||
<ClipboardCheck className="h-4 w-4 mr-2" />
|
||||
待审批
|
||||
{pendingApprovalCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-semibold">
|
||||
{pendingApprovalCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
<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={onTeamChange}>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
TeamSelector.displayName = 'TeamSelector';
|
||||
232
frontend/src/pages/Dashboard/hooks/useDeploymentData.ts
Normal file
232
frontend/src/pages/Dashboard/hooks/useDeploymentData.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getDeployEnvironments } from '../service';
|
||||
import type { DeployTeam } from '../types';
|
||||
|
||||
interface UseDeploymentDataOptions {
|
||||
onInitialLoadComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署数据管理 Hook
|
||||
* 优化点:
|
||||
* 1. 智能轮询 - 根据部署状态动态调整轮询频率
|
||||
* 2. 页面可见性检测 - 页面隐藏时暂停轮询
|
||||
* 3. 状态持久化 - 刷新时保持选中的团队和环境
|
||||
*/
|
||||
export function useDeploymentData(options: UseDeploymentDataOptions = {}) {
|
||||
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 optionsRef = useRef(options);
|
||||
const toastRef = useRef(toast);
|
||||
|
||||
// 同步 options 和 toast 到 ref
|
||||
useEffect(() => {
|
||||
optionsRef.current = options;
|
||||
toastRef.current = toast;
|
||||
}, [options, toast]);
|
||||
|
||||
// 使用 ref 存储当前选中的团队和环境,避免 loadData 依赖变化
|
||||
const currentSelectionRef = useRef({ teamId: currentTeamId, envId: currentEnvId });
|
||||
|
||||
useEffect(() => {
|
||||
currentSelectionRef.current = { teamId: currentTeamId, envId: currentEnvId };
|
||||
}, [currentTeamId, currentEnvId]);
|
||||
|
||||
// 加载部署环境数据 - 移除状态依赖,使用 ref
|
||||
const loadData = useCallback(async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const response = await getDeployEnvironments();
|
||||
|
||||
if (response && response.length > 0) {
|
||||
const prevTeamId = currentSelectionRef.current.teamId;
|
||||
const prevEnvId = currentSelectionRef.current.envId;
|
||||
|
||||
setTeams(response);
|
||||
|
||||
setIsInitialLoad(prev => {
|
||||
if (prev) {
|
||||
// 首次加载:默认选中第一个团队和第一个环境
|
||||
if (response.length > 0) {
|
||||
setCurrentTeamId(response[0].teamId);
|
||||
|
||||
if (response[0].environments.length > 0) {
|
||||
setCurrentEnvId(response[0].environments[0].environmentId);
|
||||
}
|
||||
}
|
||||
optionsRef.current.onInitialLoadComplete?.();
|
||||
return false;
|
||||
} else {
|
||||
// 后续刷新:保持当前选中的团队和环境
|
||||
if (prevTeamId !== null) {
|
||||
const teamExists = response.some(t => t.teamId === prevTeamId);
|
||||
if (teamExists) {
|
||||
setCurrentTeamId(prevTeamId);
|
||||
|
||||
if (prevEnvId !== null) {
|
||||
const team = response.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.length > 0) {
|
||||
setCurrentTeamId(response[0].teamId);
|
||||
if (response[0].environments.length > 0) {
|
||||
setCurrentEnvId(response[0].environments[0].environmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查部署状态,如果应用不再是 RUNNING 状态,从 deploying 状态中移除
|
||||
setDeploying((prevDeploying) => {
|
||||
const newDeploying = new Set(prevDeploying);
|
||||
let hasChanges = false;
|
||||
|
||||
response.forEach(team => {
|
||||
team.environments.forEach(env => {
|
||||
env.applications.forEach(app => {
|
||||
if (newDeploying.has(app.teamApplicationId)) {
|
||||
const latestStatus = app.deployStatistics?.latestStatus;
|
||||
if (latestStatus && latestStatus !== 'RUNNING') {
|
||||
newDeploying.delete(app.teamApplicationId);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return hasChanges ? newDeploying : prevDeploying;
|
||||
});
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
setIsInitialLoad(prev => {
|
||||
if (prev) {
|
||||
toastRef.current({
|
||||
variant: 'destructive',
|
||||
title: '加载失败',
|
||||
description: '无法加载部署环境数据,请稍后重试',
|
||||
});
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 首次加载数据
|
||||
useEffect(() => {
|
||||
const initData = async () => {
|
||||
await loadData(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 定时刷新数据(智能轮询) - 简化版
|
||||
useEffect(() => {
|
||||
// 初始加载时不启动轮询
|
||||
if (isInitialLoad) return;
|
||||
|
||||
// 清除旧的定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// 页面可见性检测
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
// 页面隐藏时停止轮询
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// 页面显示时恢复轮询
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
// 智能间隔:有部署时5秒,无部署时30秒
|
||||
const interval = deploying.size > 0 ? 5000 : 30000;
|
||||
intervalRef.current = setInterval(() => {
|
||||
loadData(false);
|
||||
}, interval);
|
||||
}
|
||||
};
|
||||
|
||||
// 启动轮询 - 智能间隔
|
||||
const interval = deploying.size > 0 ? 5000 : 30000;
|
||||
intervalRef.current = setInterval(() => {
|
||||
loadData(false);
|
||||
}, interval);
|
||||
|
||||
// 监听页面可见性变化
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [isInitialLoad, deploying.size, loadData]);
|
||||
|
||||
// 切换团队
|
||||
const handleTeamChange = useCallback((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);
|
||||
}
|
||||
}, [teams]);
|
||||
|
||||
// 处理部署成功
|
||||
const handleDeploySuccess = useCallback(async () => {
|
||||
await loadData(false);
|
||||
}, [loadData]);
|
||||
|
||||
// 获取当前团队和环境
|
||||
const currentTeam = teams.find(t => t.teamId === currentTeamId);
|
||||
const currentEnv = currentTeam?.environments.find(e => e.environmentId === currentEnvId);
|
||||
|
||||
return {
|
||||
loading,
|
||||
teams,
|
||||
currentTeamId,
|
||||
currentEnvId,
|
||||
deploying,
|
||||
currentTeam,
|
||||
currentEnv,
|
||||
setCurrentEnvId,
|
||||
handleTeamChange,
|
||||
handleDeploySuccess,
|
||||
refreshData: () => loadData(false)
|
||||
};
|
||||
}
|
||||
86
frontend/src/pages/Dashboard/hooks/usePendingApproval.ts
Normal file
86
frontend/src/pages/Dashboard/hooks/usePendingApproval.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { getMyApprovalTasks } from '../service';
|
||||
import type { DeployTeam } from '../types';
|
||||
|
||||
interface UsePendingApprovalOptions {
|
||||
teams: DeployTeam[];
|
||||
pollingEnabled?: boolean;
|
||||
pollingInterval?: number; // 轮询间隔,默认30秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 待审批数据管理 Hook
|
||||
* 负责管理待审批任务的加载和刷新
|
||||
* 优化:添加智能轮询,与部署数据同步刷新
|
||||
*/
|
||||
export function usePendingApproval({
|
||||
teams,
|
||||
pollingEnabled = true,
|
||||
pollingInterval = 30000 // 默认30秒
|
||||
}: UsePendingApprovalOptions) {
|
||||
const [approvalModalOpen, setApprovalModalOpen] = useState(false);
|
||||
const [pendingApprovalCount, setPendingApprovalCount] = useState(0);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 提取所有工作流定义键(去重)- 使用 useMemo 避免重复计算
|
||||
const workflowDefinitionKeys = useMemo(() => {
|
||||
const workflowKeys = teams.flatMap(team =>
|
||||
team.environments.flatMap(env =>
|
||||
env.applications
|
||||
.map(app => app.workflowDefinitionKey)
|
||||
.filter((key): key is string => !!key)
|
||||
)
|
||||
);
|
||||
return Array.from(new Set(workflowKeys));
|
||||
}, [teams]);
|
||||
|
||||
// 加载待审批数量 - 使用 useCallback 避免重复创建
|
||||
const loadPendingApprovalCount = useCallback(async () => {
|
||||
if (!pollingEnabled || workflowDefinitionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getMyApprovalTasks(workflowDefinitionKeys);
|
||||
if (response) {
|
||||
setPendingApprovalCount(response.length || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默失败,不影响主页面
|
||||
console.error('Failed to load pending approval count:', error);
|
||||
}
|
||||
}, [workflowDefinitionKeys, pollingEnabled]);
|
||||
|
||||
// 轮询待审批数量
|
||||
useEffect(() => {
|
||||
if (!pollingEnabled || workflowDefinitionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除旧的定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// 立即加载一次
|
||||
loadPendingApprovalCount();
|
||||
|
||||
// 设置定时轮询
|
||||
intervalRef.current = setInterval(loadPendingApprovalCount, pollingInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [loadPendingApprovalCount, pollingEnabled, workflowDefinitionKeys.length, pollingInterval]);
|
||||
|
||||
return {
|
||||
approvalModalOpen,
|
||||
setApprovalModalOpen,
|
||||
pendingApprovalCount,
|
||||
workflowDefinitionKeys,
|
||||
loadPendingApprovalCount,
|
||||
refreshApprovalCount: loadPendingApprovalCount
|
||||
};
|
||||
}
|
||||
@ -1,24 +1,12 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Package,
|
||||
Shield,
|
||||
Loader2,
|
||||
Users,
|
||||
Server,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
} from "lucide-react";
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '@/store';
|
||||
import { getDeployEnvironments, getMyApprovalTasks } from './service';
|
||||
import { ApplicationCard } from './components/ApplicationCard';
|
||||
import { Package, Server } from "lucide-react";
|
||||
import { TeamSelector } from './components/TeamSelector';
|
||||
import { EnvironmentTabs } from './components/EnvironmentTabs';
|
||||
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
||||
import type { DeployTeam, ApplicationConfig } from './types';
|
||||
import { useDeploymentData } from './hooks/useDeploymentData';
|
||||
import { usePendingApproval } from './hooks/usePendingApproval';
|
||||
import type { ApplicationConfig } from './types';
|
||||
|
||||
// ✅ 优化:使用骨架屏替代 loading,体验更好
|
||||
const LoadingState = () => (
|
||||
@ -41,236 +29,50 @@ const LoadingState = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Dashboard 组件 - 优化版
|
||||
*
|
||||
* 优化点:
|
||||
* 1. 拆分为更小的子组件(TeamSelector, EnvironmentTabs)
|
||||
* 2. 使用自定义 hooks 管理状态(useDeploymentData, usePendingApproval)
|
||||
* 3. 使用 React.memo 和 useCallback 优化性能
|
||||
* 4. 智能轮询 - 根据部署状态动态调整轮询频率
|
||||
*/
|
||||
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 [approvalModalOpen, setApprovalModalOpen] = useState(false);
|
||||
const [pendingApprovalCount, setPendingApprovalCount] = useState(0);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 从 Redux store 中获取当前登录用户信息
|
||||
const currentUserId = useSelector((state: RootState) => state.user.userInfo?.id);
|
||||
|
||||
// 提取所有工作流定义键(去重)
|
||||
const workflowDefinitionKeys = React.useMemo(() => {
|
||||
const workflowKeys = teams.flatMap(team =>
|
||||
team.environments.flatMap(env =>
|
||||
env.applications
|
||||
.map(app => app.workflowDefinitionKey)
|
||||
.filter((key): key is string => !!key)
|
||||
)
|
||||
);
|
||||
return Array.from(new Set(workflowKeys));
|
||||
}, [teams]);
|
||||
|
||||
// 加载待审批数量
|
||||
const loadPendingApprovalCount = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await getMyApprovalTasks(workflowDefinitionKeys);
|
||||
if (response) {
|
||||
setPendingApprovalCount(response.length || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默失败,不影响主页面
|
||||
console.error('Failed to load pending approval count:', error);
|
||||
// 使用自定义 hooks 管理状态
|
||||
const deploymentData = useDeploymentData({
|
||||
onInitialLoadComplete: () => {
|
||||
// 初始加载完成后,加载待审批数据
|
||||
approvalData.loadPendingApprovalCount();
|
||||
}
|
||||
}, [workflowDefinitionKeys]);
|
||||
});
|
||||
|
||||
// 加载部署环境数据
|
||||
const loadData = React.useCallback(async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
const approvalData = usePendingApproval({
|
||||
teams: deploymentData.teams,
|
||||
pollingEnabled: !deploymentData.loading
|
||||
});
|
||||
|
||||
const response = await getDeployEnvironments();
|
||||
|
||||
// ✅ 新接口直接返回 teams 数组
|
||||
if (response && response.length > 0) {
|
||||
const prevTeamId = currentTeamId;
|
||||
const prevEnvId = currentEnvId;
|
||||
|
||||
setTeams(response);
|
||||
|
||||
if (isInitialLoad) {
|
||||
// 首次加载:默认选中第一个团队和第一个环境
|
||||
if (response.length > 0) {
|
||||
setCurrentTeamId(response[0].teamId);
|
||||
|
||||
if (response[0].environments.length > 0) {
|
||||
setCurrentEnvId(response[0].environments[0].environmentId);
|
||||
}
|
||||
}
|
||||
setIsInitialLoad(false);
|
||||
} else {
|
||||
// 后续刷新:保持当前选中的团队和环境
|
||||
// 如果之前选中的团队/环境仍然存在,保持选中
|
||||
if (prevTeamId !== null) {
|
||||
const teamExists = response.some(t => t.teamId === prevTeamId);
|
||||
if (teamExists) {
|
||||
setCurrentTeamId(prevTeamId);
|
||||
|
||||
if (prevEnvId !== null) {
|
||||
const team = response.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.length > 0) {
|
||||
// 如果之前的团队不存在了,选择第一个团队
|
||||
setCurrentTeamId(response[0].teamId);
|
||||
if (response[0].environments.length > 0) {
|
||||
setCurrentEnvId(response[0].environments[0].environmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查部署状态,如果应用不再是 RUNNING 状态,从 deploying 状态中移除
|
||||
setDeploying((prevDeploying) => {
|
||||
const newDeploying = new Set(prevDeploying);
|
||||
let hasChanges = false;
|
||||
|
||||
// 遍历所有团队、环境、应用,检查部署状态
|
||||
response.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(() => {
|
||||
// ✅ 优化:异步加载数据,不阻塞页面渲染
|
||||
// 先渲染骨架,然后后台加载数据
|
||||
const initData = async () => {
|
||||
await Promise.all([
|
||||
loadData(false), // 不显示 loading,直接显示内容
|
||||
loadPendingApprovalCount()
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initData();
|
||||
if (deploymentData.teams.length > 0) {
|
||||
approvalData.loadPendingApprovalCount();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [deploymentData.teams.length]);
|
||||
|
||||
// 定时刷新数据(每5秒)
|
||||
useEffect(() => {
|
||||
// 只在非首次加载后开始轮询
|
||||
if (isInitialLoad) return;
|
||||
// 处理部署成功 - 使用 useCallback 避免重复创建
|
||||
const handleDeploy = useCallback(async (app: ApplicationConfig, remark: string) => {
|
||||
await deploymentData.handleDeploySuccess();
|
||||
await approvalData.refreshApprovalCount();
|
||||
}, [deploymentData.handleDeploySuccess, approvalData.refreshApprovalCount]);
|
||||
|
||||
// 启动定时器的函数
|
||||
const startPolling = () => {
|
||||
// 清除旧的定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
// 立即刷新一次
|
||||
loadData(false);
|
||||
loadPendingApprovalCount();
|
||||
// 设置新的定时器
|
||||
intervalRef.current = setInterval(() => {
|
||||
loadData(false);
|
||||
loadPendingApprovalCount();
|
||||
}, 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理部署成功后的回调(刷新数据)
|
||||
// 注意:实际的部署提交已在 DeploymentFormModal 中完成
|
||||
const handleDeploy = async (app: ApplicationConfig, remark: string) => {
|
||||
// 部署成功后,刷新数据以获取最新状态
|
||||
await loadData(false);
|
||||
};
|
||||
|
||||
// 获取当前团队和环境
|
||||
const currentTeam = teams.find(t => t.teamId === currentTeamId);
|
||||
const currentEnv = currentTeam?.environments.find(e => e.environmentId === currentEnvId);
|
||||
|
||||
if (loading) {
|
||||
// 加载状态
|
||||
if (deploymentData.loading) {
|
||||
return <LoadingState/>;
|
||||
}
|
||||
|
||||
if (teams.length === 0) {
|
||||
// 无团队状态
|
||||
if (deploymentData.teams.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 p-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-6">部署管理</h2>
|
||||
@ -295,171 +97,56 @@ const Dashboard: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 待审批按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-orange-500 text-orange-600 hover:bg-orange-50 hover:text-orange-700 hover:border-orange-600"
|
||||
onClick={() => setApprovalModalOpen(true)}
|
||||
>
|
||||
<ClipboardCheck className="h-4 w-4 mr-2" />
|
||||
待审批
|
||||
{pendingApprovalCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-semibold">
|
||||
{pendingApprovalCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
<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>
|
||||
<TeamSelector
|
||||
teams={deploymentData.teams}
|
||||
currentTeamId={deploymentData.currentTeamId}
|
||||
pendingApprovalCount={approvalData.pendingApprovalCount}
|
||||
onTeamChange={deploymentData.handleTeamChange}
|
||||
onApprovalClick={() => approvalData.setApprovalModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前团队信息 */}
|
||||
{currentTeam && (
|
||||
{deploymentData.currentTeam && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{currentTeam.teamName}</span>
|
||||
{currentTeam.description && (
|
||||
<span className="font-medium text-foreground">{deploymentData.currentTeam.teamName}</span>
|
||||
{deploymentData.currentTeam.description && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{currentTeam.description}</span>
|
||||
<span>{deploymentData.currentTeam.description}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 环境切换和应用列表 */}
|
||||
{currentTeam && (
|
||||
currentTeam.environments.length === 0 ? (
|
||||
{deploymentData.currentTeam && (
|
||||
deploymentData.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}」还没有配置任何部署环境
|
||||
当前团队「{deploymentData.currentTeam.teamName}」还没有配置任何部署环境
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs value={currentEnvId?.toString()} onValueChange={(value) => setCurrentEnvId(Number(value))}>
|
||||
{/* 现代化 TAB 头部 - 独立于 Card */}
|
||||
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
||||
<div className="flex items-center justify-between px-6 pt-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-1 bg-primary rounded-full" />
|
||||
<h3 className="text-lg font-semibold">部署环境</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-h-[20px]">
|
||||
{currentEnv && currentEnv.requiresApproval && currentEnv.approvers.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800">
|
||||
<Shield className="h-3.5 w-3.5 text-amber-600" />
|
||||
<span className="text-amber-900 dark:text-amber-200 font-medium">
|
||||
需审批: {currentEnv.approvers.map((a) => a.realName).join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-3">
|
||||
<div className="inline-flex h-11 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground gap-1">
|
||||
{currentTeam.environments.map((env) => (
|
||||
<button
|
||||
key={env.environmentId}
|
||||
onClick={() => setCurrentEnvId(env.environmentId)}
|
||||
className={`
|
||||
inline-flex items-center justify-center whitespace-nowrap rounded-md px-4 py-2
|
||||
text-sm font-medium ring-offset-background transition-all
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
${currentEnvId === env.environmentId
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-background/50 hover:text-foreground'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{env.requiresApproval ? (
|
||||
<Shield className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-amber-600' : 'text-amber-500/60'}`} />
|
||||
) : (
|
||||
<CheckCircle2 className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-green-600' : 'text-green-500/60'}`} />
|
||||
)}
|
||||
<span>{env.environmentName}</span>
|
||||
<span className={`
|
||||
ml-1 rounded-full px-2 py-0.5 text-xs font-semibold
|
||||
${currentEnvId === env.environmentId
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
}
|
||||
`}>
|
||||
{env.applications.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{currentTeam.environments.map((env) => (
|
||||
<TabsContent key={env.environmentId} value={env.environmentId.toString()} className="mt-0">
|
||||
<div className="p-6">
|
||||
{/* 应用列表 */}
|
||||
{env.applications.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="rounded-full bg-muted p-6 mb-4">
|
||||
<Package className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">暂无可部署应用</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
环境「{env.environmentName}」暂未配置任何应用
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{env.applications.map((app) => (
|
||||
<ApplicationCard
|
||||
key={app.teamApplicationId}
|
||||
app={app}
|
||||
environment={env}
|
||||
teamId={currentTeam?.teamId || 0}
|
||||
onDeploy={handleDeploy}
|
||||
isDeploying={deploying.has(app.teamApplicationId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
<EnvironmentTabs
|
||||
team={deploymentData.currentTeam}
|
||||
currentEnvId={deploymentData.currentEnvId}
|
||||
deploying={deploymentData.deploying}
|
||||
onEnvChange={deploymentData.setCurrentEnvId}
|
||||
onDeploy={handleDeploy}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 待审批列表弹窗 */}
|
||||
<PendingApprovalModal
|
||||
open={approvalModalOpen}
|
||||
onOpenChange={setApprovalModalOpen}
|
||||
workflowDefinitionKeys={workflowDefinitionKeys}
|
||||
open={approvalData.approvalModalOpen}
|
||||
onOpenChange={approvalData.setApprovalModalOpen}
|
||||
workflowDefinitionKeys={approvalData.workflowDefinitionKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user