增加团队管理页面

This commit is contained in:
dengqichen 2025-10-31 15:08:12 +08:00
parent 2ab9ebff70
commit 19a1bc86ff
11 changed files with 1165 additions and 79 deletions

View File

@ -49,12 +49,14 @@ const sheetVariants = cva(
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {} VariantProps<typeof sheetVariants> {
showClose?: boolean
}
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>, React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => ( >(({ side = "right", className, children, showClose = true, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
@ -63,10 +65,12 @@ const SheetContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> {showClose && (
<X className="h-4 w-4" /> <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<span className="sr-only">Close</span> <X className="h-4 w-4" />
</SheetPrimitive.Close> <span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)) ))

View File

@ -163,12 +163,14 @@ const EnvironmentList: React.FC = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<Table minWidth="950px"> <Table minWidth="1150px">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead width="120px"></TableHead> <TableHead width="120px"></TableHead>
<TableHead width="150px"></TableHead> <TableHead width="150px"></TableHead>
<TableHead width="300px"></TableHead> <TableHead width="250px"></TableHead>
<TableHead width="100px"></TableHead>
<TableHead width="100px"></TableHead>
<TableHead width="80px"></TableHead> <TableHead width="80px"></TableHead>
<TableHead width="100px"></TableHead> <TableHead width="100px"></TableHead>
<TableHead width="200px" sticky></TableHead> <TableHead width="200px" sticky></TableHead>
@ -177,7 +179,7 @@ const EnvironmentList: React.FC = () => {
<TableBody> <TableBody>
{list.length === 0 ? ( {list.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-24 text-center"> <TableCell colSpan={8} className="h-24 text-center">
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -187,7 +189,21 @@ const EnvironmentList: React.FC = () => {
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell width="120px" className="font-medium">{item.envCode}</TableCell> <TableCell width="120px" className="font-medium">{item.envCode}</TableCell>
<TableCell width="150px">{item.envName}</TableCell> <TableCell width="150px">{item.envName}</TableCell>
<TableCell width="300px" className="text-muted-foreground">{item.envDesc || '-'}</TableCell> <TableCell width="250px" className="text-muted-foreground">{item.envDesc || '-'}</TableCell>
<TableCell width="100px">
<div className="text-center">
<Badge variant="outline" className="font-medium">
{item.teamCount || 0}
</Badge>
</div>
</TableCell>
<TableCell width="100px">
<div className="text-center">
<Badge variant="outline" className="font-medium">
{item.applicationCount || 0}
</Badge>
</div>
</TableCell>
<TableCell width="80px">{item.sort}</TableCell> <TableCell width="80px">{item.sort}</TableCell>
<TableCell width="100px"> <TableCell width="100px">
<Badge variant={item.enabled ? "default" : "secondary"} className="inline-flex"> <Badge variant={item.enabled ? "default" : "secondary"} className="inline-flex">

View File

@ -7,6 +7,8 @@ export interface Environment extends BaseResponse {
envName: string; envName: string;
envDesc?: string; envDesc?: string;
sort: number; sort: number;
teamCount?: number;
applicationCount?: number;
} }
// 创建环境请求参数 // 创建环境请求参数

View File

@ -12,7 +12,7 @@ import {
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText, BarChart3, List, Ban Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText, BarChart3, List, Ban
} from 'lucide-react'; } from 'lucide-react';
import {useToast} from '@/components/ui/use-toast'; import {useToast} from '@/components/ui/use-toast';
import {getScheduleJobs, getJobCategoryList, startJob, pauseJob, resumeJob, stopJob, triggerJob, disableJob, deleteScheduleJob, enableJob} from './service'; import {getScheduleJobs, getJobCategoryList, pauseJob, resumeJob, triggerJob, disableJob, deleteScheduleJob, enableJob} from './service';
import type {ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus} from './types'; import type {ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus} from './types';
import type {Page} from '@/types/base'; import type {Page} from '@/types/base';
import {DEFAULT_PAGE_SIZE, DEFAULT_CURRENT} from '@/utils/page'; import {DEFAULT_PAGE_SIZE, DEFAULT_CURRENT} from '@/utils/page';
@ -141,25 +141,6 @@ const ScheduleJobList: React.FC = () => {
} }
}; };
// 启动任务
const handleStart = async (record: ScheduleJobResponse) => {
try {
await startJob(record.id);
toast({
title: '启动成功',
description: `任务 "${record.jobName}" 已启动`,
});
loadData();
} catch (error) {
console.error('启动失败:', error);
toast({
variant: 'destructive',
title: '启动失败',
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 暂停任务 // 暂停任务
const handlePause = async (record: ScheduleJobResponse) => { const handlePause = async (record: ScheduleJobResponse) => {
try { try {
@ -198,25 +179,6 @@ const ScheduleJobList: React.FC = () => {
} }
}; };
// 停止任务
const handleStop = async (record: ScheduleJobResponse) => {
try {
await stopJob(record.id);
toast({
title: '停止成功',
description: `任务 "${record.jobName}" 已停止`,
});
loadData();
} catch (error) {
console.error('停止失败:', error);
toast({
variant: 'destructive',
title: '停止失败',
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 立即触发任务 // 立即触发任务
const handleTrigger = async (record: ScheduleJobResponse) => { const handleTrigger = async (record: ScheduleJobResponse) => {
try { try {

View File

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { import {
Server,
Cpu, Cpu,
HardDrive, HardDrive,
MemoryStick, MemoryStick,
@ -62,7 +61,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({
server.status === 'OFFLINE' ? 'bg-red-500/10 text-red-700 border-red-500/30 dark:text-red-400' : server.status === 'OFFLINE' ? 'bg-red-500/10 text-red-700 border-red-500/30 dark:text-red-400' :
'bg-gray-500/10 text-gray-700 border-gray-500/30 dark:text-gray-400' 'bg-gray-500/10 text-gray-700 border-gray-500/30 dark:text-gray-400'
}`}> }`}>
{ServerStatusLabels[server.status]?.label || server.status} {(server.status && ServerStatusLabels[server.status]?.label) || server.status || '-'}
</Badge> </Badge>
</div> </div>
{server.categoryName && ( {server.categoryName && (
@ -94,7 +93,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({
)} )}
{server.osType && ( {server.osType && (
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
{OsTypeLabels[server.osType]?.label || server.osType} {(server.osType && OsTypeLabels[server.osType]?.label) || server.osType || '-'}
{server.osVersion && ` ${server.osVersion}`} {server.osVersion && ` ${server.osVersion}`}
</div> </div>
)} )}
@ -175,7 +174,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="sm"
onClick={() => onTest(server)} onClick={() => onTest(server)}
disabled={isTesting} disabled={isTesting}
className="h-7 px-2" className="h-7 px-2"
@ -193,7 +192,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="sm"
onClick={() => onEdit(server)} onClick={() => onEdit(server)}
className="h-7 px-2" className="h-7 px-2"
> >
@ -206,7 +205,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="sm"
onClick={() => onDelete(server)} onClick={() => onDelete(server)}
className="h-7 px-2 text-destructive hover:text-destructive" className="h-7 px-2 text-destructive hover:text-destructive"
> >

View File

@ -40,7 +40,6 @@ interface ServerTableProps {
export const ServerTable: React.FC<ServerTableProps> = ({ export const ServerTable: React.FC<ServerTableProps> = ({
servers, servers,
loading,
onTest, onTest,
onEdit, onEdit,
onDelete, onDelete,
@ -106,14 +105,14 @@ export const ServerTable: React.FC<ServerTableProps> = ({
server.status === 'OFFLINE' ? 'bg-red-500/10 text-red-700 border-red-500/30 dark:text-red-400' : server.status === 'OFFLINE' ? 'bg-red-500/10 text-red-700 border-red-500/30 dark:text-red-400' :
'bg-gray-500/10 text-gray-700 border-gray-500/30 dark:text-gray-400' 'bg-gray-500/10 text-gray-700 border-gray-500/30 dark:text-gray-400'
}> }>
{ServerStatusLabels[server.status]?.label || server.status} {(server.status && ServerStatusLabels[server.status]?.label) || server.status || '-'}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{getOsIcon(server.osType)} {getOsIcon(server.osType)}
<span className="text-sm">{OsTypeLabels[server.osType]?.label || server.osType}</span> <span className="text-sm">{(server.osType && OsTypeLabels[server.osType]?.label) || server.osType || '-'}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@ -14,7 +14,7 @@ import {
Grid3x3, Grid3x3,
List, List,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';

View File

@ -0,0 +1,957 @@
import React, { useState, useEffect } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/components/ui/use-toast';
import { Plus, Trash2, Loader2, AlertCircle } from 'lucide-react';
import type {
TeamConfig,
TeamConfigRequest,
TeamApplication,
TeamApplicationRequest,
Environment,
Application,
} from '../types';
import type { UserResponse } from '@/pages/System/User/types';
import {
getTeamConfig,
createTeamConfig,
updateTeamConfig,
getTeamApplications,
createTeamApplication,
deleteTeamApplication,
} from '../service';
interface TeamConfigSheetProps {
open: boolean;
teamId: number;
teamName: string;
users: UserResponse[];
environments: Environment[];
applications: Application[];
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
const TeamConfigSheet: React.FC<TeamConfigSheetProps> = ({
open,
teamId,
teamName,
users,
environments,
applications,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
// 状态管理
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [teamConfig, setTeamConfig] = useState<TeamConfig | null>(null);
const [teamApplications, setTeamApplications] = useState<TeamApplication[]>([]);
// 步骤管理
const [currentStep, setCurrentStep] = useState(1); // 1: 环境选择, 2: 环境配置, 3: 配置预览
const [currentConfigEnvId, setCurrentConfigEnvId] = useState<number | null>(null); // 当前正在配置的环境ID
// 表单状态 - 改为以环境ID为key的对象结构
const [selectedEnvs, setSelectedEnvs] = useState<number[]>([]);
const [envConfigs, setEnvConfigs] = useState<Record<number, {
approvalRequired: boolean;
approverUserIds: number[];
}>>({}); // 每个环境的审批配置
const [configuredEnvs, setConfiguredEnvs] = useState<number[]>([]); // 已配置的环境列表
// 每个环境的添加表单状态
const [addForms, setAddForms] = useState<Record<number, { appId: number | null; branch: string }>>({});
// 加载数据
useEffect(() => {
if (open && teamId) {
loadData();
setCurrentStep(1); // 重置为第一步
}
}, [open, teamId]);
const loadData = async () => {
setLoading(true);
try {
await Promise.all([loadTeamConfig(), loadTeamApplications()]);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
};
const loadTeamConfig = async () => {
try {
const config = await getTeamConfig(teamId);
if (config) {
setTeamConfig(config);
setSelectedEnvs(config.allowedEnvironmentIds || []);
// 转换为新的数据结构
const configs: Record<number, { approvalRequired: boolean; approverUserIds: number[] }> = {};
config.allowedEnvironmentIds.forEach((envId, index) => {
configs[envId] = {
approvalRequired: config.environmentApprovalRequired[index] || false,
approverUserIds: config.approverUserIds[index] || [],
};
});
setEnvConfigs(configs);
setConfiguredEnvs(config.allowedEnvironmentIds);
} else {
// 没有配置数据,重置为空
setSelectedEnvs([]);
setEnvConfigs({});
setConfiguredEnvs([]);
}
} catch (error: any) {
// 404 表示配置不存在,这是正常的,重置为空
if (error.response?.status === 404) {
setTeamConfig(null);
setSelectedEnvs([]);
setEnvConfigs({});
setConfiguredEnvs([]);
} else {
console.error('加载团队配置失败:', error);
}
}
};
const loadTeamApplications = async () => {
try {
const response = await getTeamApplications({ teamId, pageNum: 0, size: 1000 });
if (response) {
setTeamApplications(response.content || []);
}
} catch (error) {
console.error('加载团队应用失败:', error);
}
};
// 处理环境选择
const handleEnvToggle = (envId: number, checked: boolean) => {
if (checked) {
setSelectedEnvs([...selectedEnvs, envId]);
// 初始化该环境的配置
if (!envConfigs[envId]) {
setEnvConfigs({
...envConfigs,
[envId]: { approvalRequired: false, approverUserIds: [] },
});
}
} else {
setSelectedEnvs(selectedEnvs.filter((id) => id !== envId));
// 移除该环境的配置
const newConfigs = { ...envConfigs };
delete newConfigs[envId];
setEnvConfigs(newConfigs);
setConfiguredEnvs(configuredEnvs.filter((id) => id !== envId));
}
};
// 处理当前环境的审批开关
const handleCurrentEnvApprovalToggle = (checked: boolean) => {
if (!currentConfigEnvId) return;
setEnvConfigs({
...envConfigs,
[currentConfigEnvId]: {
...envConfigs[currentConfigEnvId],
approvalRequired: checked,
approverUserIds: checked ? envConfigs[currentConfigEnvId]?.approverUserIds || [] : [],
},
});
};
// 处理当前环境的审批人选择
const handleCurrentEnvApproverToggle = (userId: number, checked: boolean) => {
if (!currentConfigEnvId) return;
const currentApprovers = envConfigs[currentConfigEnvId]?.approverUserIds || [];
const newApprovers = checked
? [...currentApprovers, userId]
: currentApprovers.filter((id) => id !== userId);
setEnvConfigs({
...envConfigs,
[currentConfigEnvId]: {
...envConfigs[currentConfigEnvId],
approverUserIds: newApprovers,
},
});
};
// 步骤导航
const handleNext = () => {
// Step 1: 环境选择 → Step 2: 环境配置 或 直接保存
if (currentStep === 1) {
if (selectedEnvs.length === 0) {
// 没有选择环境,直接保存空配置
handleSaveConfig();
return;
}
// 进入 Step 2默认选择第一个环境
setCurrentConfigEnvId(selectedEnvs[0]);
setCurrentStep(2);
return;
}
// Step 2: 环境配置 → Step 3: 配置预览
if (currentStep === 2) {
// 校验当前环境的配置
if (currentConfigEnvId) {
const config = envConfigs[currentConfigEnvId];
if (config?.approvalRequired && (!config.approverUserIds || config.approverUserIds.length === 0)) {
const env = environments.find((e) => e.id === currentConfigEnvId);
toast({
variant: 'destructive',
title: `环境"${env?.envName}"需要审批,请至少选择一个审批人`,
});
return;
}
// 标记当前环境已配置
if (!configuredEnvs.includes(currentConfigEnvId)) {
setConfiguredEnvs([...configuredEnvs, currentConfigEnvId]);
}
}
// 检查是否所有环境都已配置
const unconfiguredEnvs = selectedEnvs.filter((id) => !configuredEnvs.includes(id) && id !== currentConfigEnvId);
if (unconfiguredEnvs.length > 0) {
// 还有未配置的环境,切换到下一个
setCurrentConfigEnvId(unconfiguredEnvs[0]);
toast({
title: '当前环境配置已保存',
description: `请继续配置 "${environments.find((e) => e.id === unconfiguredEnvs[0])?.envName}"`,
});
} else {
// 所有环境都配置完了,进入预览
setCurrentStep(3);
}
return;
}
};
const handlePrev = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
// 切换配置的环境
const handleSwitchConfigEnv = (envId: number) => {
// 先保存当前环境的配置状态
if (currentConfigEnvId && !configuredEnvs.includes(currentConfigEnvId)) {
const config = envConfigs[currentConfigEnvId];
// 如果已经有配置内容,标记为已配置
if (config && (config.approvalRequired || (envApps(currentConfigEnvId).length > 0))) {
setConfiguredEnvs([...configuredEnvs, currentConfigEnvId]);
}
}
setCurrentConfigEnvId(envId);
};
// 跳过配置(直接进入预览)
const handleSkipToPreview = () => {
if (currentConfigEnvId && !configuredEnvs.includes(currentConfigEnvId)) {
setConfiguredEnvs([...configuredEnvs, currentConfigEnvId]);
}
setCurrentStep(3);
};
// 保存最终配置
const handleSaveConfig = async () => {
setSaving(true);
try {
// 如果没有选择任何环境,清空所有配置
if (selectedEnvs.length === 0) {
const configData: TeamConfigRequest = {
teamId,
allowedEnvironmentIds: [],
environmentApprovalRequired: [],
approverUserIds: [],
};
if (teamConfig) {
await updateTeamConfig(teamConfig.id, configData);
} else {
const newConfig = await createTeamConfig(configData);
setTeamConfig(newConfig);
}
toast({
title: '保存成功',
description: '已清空团队配置',
});
onSuccess();
onOpenChange(false);
return;
}
// 只保存已勾选环境的配置
const allowedEnvironmentIds = selectedEnvs;
const environmentApprovalRequired = selectedEnvs.map(
(envId) => envConfigs[envId]?.approvalRequired || false
);
const approverUserIds = selectedEnvs.map(
(envId) => envConfigs[envId]?.approverUserIds || null
);
const configData: TeamConfigRequest = {
teamId,
allowedEnvironmentIds,
environmentApprovalRequired,
approverUserIds,
};
if (teamConfig) {
// 更新
await updateTeamConfig(teamConfig.id, configData);
} else {
// 创建
const newConfig = await createTeamConfig(configData);
setTeamConfig(newConfig);
}
toast({
title: '保存成功',
description: '团队配置已保存',
});
onSuccess();
onOpenChange(false); // 关闭抽屉
} catch (error: any) {
toast({
variant: 'destructive',
title: '保存失败',
description: error.response?.data?.message || '保存配置失败',
});
} finally {
setSaving(false);
}
};
// 获取某个环境的应用列表(辅助函数)
const envApps = (envId: number) => {
return teamApplications.filter((app) => app.environmentId === envId);
};
// 添加应用到环境
const handleAddApplication = async (envId: number) => {
const form = addForms[envId];
if (!form?.appId) {
toast({
variant: 'destructive',
title: '请选择应用',
});
return;
}
// 检查是否已存在
const exists = teamApplications.some(
(app) => app.environmentId === envId && app.applicationId === form.appId
);
if (exists) {
toast({
variant: 'destructive',
title: '该应用已添加到此环境',
});
return;
}
try {
const data: TeamApplicationRequest = {
teamId,
applicationId: form.appId,
environmentId: envId,
branch: form.branch || undefined,
};
await createTeamApplication(data);
toast({
title: '添加成功',
});
// 清空表单
setAddForms({
...addForms,
[envId]: { appId: null, branch: '' },
});
// 重新加载
loadTeamApplications();
onSuccess();
} catch (error: any) {
toast({
variant: 'destructive',
title: '添加失败',
description: error.response?.data?.message || '添加应用失败',
});
}
};
// 删除应用
const handleDeleteApplication = async (id: number) => {
try {
await deleteTeamApplication(id);
toast({
title: '删除成功',
});
// 重新加载
loadTeamApplications();
onSuccess();
} catch (error: any) {
toast({
variant: 'destructive',
title: '删除失败',
description: error.response?.data?.message || '删除应用失败',
});
}
};
// 获取某个环境下的应用列表
const getEnvApplications = (envId: number) => {
return teamApplications.filter((app) => app.environmentId === envId);
};
// 获取环境图标
const getEnvIcon = (envCode?: string) => {
const code = envCode?.toUpperCase();
if (code?.includes('DEV')) return '🟢';
if (code?.includes('TEST') || code?.includes('QA')) return '🟡';
if (code?.includes('UAT') || code?.includes('PRE')) return '🟠';
if (code?.includes('PROD')) return '🔴';
return '⚪';
};
// 获取可添加的应用列表(排除已添加的)
const getAvailableApplications = (envId: number) => {
const addedAppIds = getEnvApplications(envId).map((app) => app.applicationId);
return applications.filter((app) => !addedAppIds.includes(app.id));
};
// 渲染步骤指示器
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-4 py-4 border-b">
{[
{ step: 1, label: '环境选择' },
{ step: 2, label: '环境配置' },
{ step: 3, label: '配置预览' },
].map((item, index) => (
<React.Fragment key={item.step}>
<div className="flex items-center gap-2">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
currentStep === item.step
? 'bg-primary text-primary-foreground'
: currentStep > item.step
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{item.step}
</div>
<span
className={`text-sm font-medium ${
currentStep === item.step
? 'text-foreground'
: currentStep > item.step
? 'text-primary'
: 'text-muted-foreground'
}`}
>
{item.label}
</span>
</div>
{index < 2 && (
<div
className={`h-[2px] w-12 ${
currentStep > item.step ? 'bg-primary' : 'bg-muted'
}`}
/>
)}
</React.Fragment>
))}
</div>
);
return (
<Sheet open={open}>
<SheetContent side="right" className="w-[700px] sm:max-w-[700px]" showClose={false}>
<SheetHeader>
<SheetTitle>{teamName}</SheetTitle>
</SheetHeader>
{loading ? (
<div className="flex items-center justify-center h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* 步骤指示器 */}
{renderStepIndicator()}
<ScrollArea className="h-[calc(100vh-240px)] mt-6 pr-4">
<div className="space-y-6">
{/* Step 1: 环境选择 */}
{currentStep === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">访</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
访
</p>
</CardHeader>
<CardContent>
{environments.length === 0 ? (
<div className="p-8 border border-dashed rounded-md text-center">
<AlertCircle className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground"></p>
<p className="text-xs text-muted-foreground mt-1">
"环境管理"
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
{environments.map((env) => (
<div
key={env.id}
className={`flex items-center space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${
selectedEnvs.includes(env.id)
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
onClick={() => handleEnvToggle(env.id, !selectedEnvs.includes(env.id))}
>
<Checkbox
id={`env-${env.id}`}
checked={selectedEnvs.includes(env.id)}
onCheckedChange={(checked) =>
handleEnvToggle(env.id, checked as boolean)
}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-lg">{getEnvIcon(env.envCode)}</span>
<label
htmlFor={`env-${env.id}`}
className="font-medium cursor-pointer"
>
{env.envName}
</label>
</div>
<p className="text-xs text-muted-foreground mt-1">
{env.envCode}
</p>
</div>
</div>
))}
</div>
)}
{selectedEnvs.length > 0 ? (
<div className="mt-4 p-3 bg-muted/50 rounded-md">
<p className="text-sm">
<span className="font-medium text-primary">{selectedEnvs.length}</span>
</p>
</div>
) : (
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
💡 "下一步"
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Step 2: 环境配置(侧边导航式) */}
{currentStep === 2 && currentConfigEnvId && (
<div className="grid grid-cols-[200px_1fr] gap-4">
{/* 左侧:环境列表 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{selectedEnvs.map((envId) => {
const env = environments.find((e) => e.id === envId);
if (!env) return null;
const isConfigured = configuredEnvs.includes(envId);
const isCurrent = envId === currentConfigEnvId;
return (
<div
key={envId}
className={`flex items-center gap-2 p-2 rounded-md cursor-pointer transition-colors ${
isCurrent
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
}`}
onClick={() => handleSwitchConfigEnv(envId)}
>
<span className="text-sm">{getEnvIcon(env.envCode)}</span>
<span className="flex-1 text-sm font-medium truncate">
{env.envName}
</span>
{isConfigured && !isCurrent && (
<span className="text-xs text-primary"></span>
)}
</div>
);
})}
</CardContent>
</Card>
{/* 右侧:当前环境配置 */}
<div className="space-y-4">
{(() => {
const currentEnv = environments.find((e) => e.id === currentConfigEnvId);
const currentConfig = envConfigs[currentConfigEnvId] || { approvalRequired: false, approverUserIds: [] };
return (
<>
{/* 环境审批配置 */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<span className="text-lg">{getEnvIcon(currentEnv?.envCode)}</span>
<CardTitle className="text-base">{currentEnv?.envName}</CardTitle>
</div>
<p className="text-xs text-muted-foreground mt-1">
</p>
</CardHeader>
<CardContent className="space-y-4">
{/* 审批开关 */}
<div className="flex items-center justify-between p-3 border rounded-md">
<Label className="text-sm"></Label>
<Checkbox
checked={currentConfig.approvalRequired}
onCheckedChange={handleCurrentEnvApprovalToggle}
/>
</div>
{/* 审批人选择 */}
{currentConfig.approvalRequired && (
<div className="space-y-2">
<Label className="text-sm"></Label>
<div className="grid grid-cols-2 gap-2 max-h-[200px] overflow-y-auto p-3 border rounded-md bg-muted/30">
{users.map((user) => (
<div key={user.id} className="flex items-center space-x-2">
<Checkbox
id={`approver-${user.id}`}
checked={currentConfig.approverUserIds.includes(user.id)}
onCheckedChange={(checked) =>
handleCurrentEnvApproverToggle(user.id, checked as boolean)
}
/>
<label
htmlFor={`approver-${user.id}`}
className="text-xs cursor-pointer"
>
{user.nickname || user.username}
</label>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* 应用分支配置 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{applications.length === 0 ? (
<div className="p-4 border border-dashed rounded-md text-center">
<AlertCircle className="h-6 w-6 mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{envApps(currentConfigEnvId).map((app) => (
<TableRow key={app.id}>
<TableCell className="font-medium">
{app.applicationName}
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-1 rounded">
{app.branch || '-'}
</code>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteApplication(app.id)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
{/* 添加行 */}
{getAvailableApplications(currentConfigEnvId).length > 0 && (
<TableRow className="bg-muted/20">
<TableCell>
<Select
value={addForms[currentConfigEnvId]?.appId?.toString() || ''}
onValueChange={(value) => {
setAddForms({
...addForms,
[currentConfigEnvId]: {
...addForms[currentConfigEnvId],
appId: Number(value),
},
});
}}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="选择应用" />
</SelectTrigger>
<SelectContent>
{getAvailableApplications(currentConfigEnvId).map((app) => (
<SelectItem key={app.id} value={app.id.toString()}>
{app.appName}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
placeholder="分支名"
className="h-8"
value={addForms[currentConfigEnvId]?.branch || ''}
onChange={(e) => {
setAddForms({
...addForms,
[currentConfigEnvId]: {
...addForms[currentConfigEnvId],
branch: e.target.value,
},
});
}}
/>
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
className="h-8"
onClick={() => handleAddApplication(currentConfigEnvId)}
disabled={!addForms[currentConfigEnvId]?.appId}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</TableCell>
</TableRow>
)}
{envApps(currentConfigEnvId).length === 0 && getAvailableApplications(currentConfigEnvId).length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center text-sm text-muted-foreground py-4">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
})()}
</div>
</div>
)}
{/* Step 3: 配置预览 */}
{currentStep === 3 && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<p className="text-sm text-muted-foreground mt-1">
"完成配置"
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
{selectedEnvs.map((envId) => {
const env = environments.find((e) => e.id === envId);
if (!env) return null;
const config = envConfigs[envId] || { approvalRequired: false, approverUserIds: [] };
const apps = envApps(envId);
return (
<Card key={envId} className="border-muted">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{getEnvIcon(env.envCode)}</span>
<CardTitle className="text-base">{env.envName}</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCurrentConfigEnvId(envId);
setCurrentStep(2);
}}
>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* 审批配置信息 */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
{config.approvalRequired ? (
<>
<Badge variant="default" className="text-xs"></Badge>
{config.approverUserIds.length > 0 && (
<span className="text-xs text-muted-foreground">
{config.approverUserIds
.map((userId) => {
const user = users.find((u) => u.id === userId);
return user?.nickname || user?.username;
})
.filter(Boolean)
.join('、')}
</span>
)}
</>
) : (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
</div>
{/* 应用配置信息 */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"></Label>
{apps.length > 0 ? (
<div className="space-y-1">
{apps.map((app) => (
<div key={app.id} className="flex items-center justify-between text-xs p-2 bg-muted/30 rounded">
<span>{app.applicationName}</span>
<code className="text-xs bg-background px-2 py-0.5 rounded">
{app.branch || '-'}
</code>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground"></p>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
{/* 底部导航按钮 */}
<SheetFooter className="mt-4 flex justify-between items-center">
<Button
variant="outline"
onClick={handlePrev}
disabled={currentStep === 1 || saving}
>
</Button>
<div className="flex gap-2">
{currentStep === 2 && (
<Button
variant="outline"
onClick={handleSkipToPreview}
disabled={saving}
>
</Button>
)}
{currentStep < 3 ? (
<Button onClick={handleNext}>
{currentStep === 1 && selectedEnvs.length === 0
? '保存(清空配置)'
: currentStep === 2 && selectedEnvs.filter((id) => !configuredEnvs.includes(id) && id !== currentConfigEnvId).length > 0
? '配置下一个环境'
: '下一步'}
</Button>
) : (
<Button onClick={handleSaveConfig} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
)}
</div>
</SheetFooter>
</>
)}
</SheetContent>
</Sheet>
);
};
export default TeamConfigSheet;

View File

@ -5,11 +5,11 @@ import type { TeamResponse, TeamQuery } from './types';
import TeamModal from './components/TeamModal'; import TeamModal from './components/TeamModal';
import DeleteDialog from './components/DeleteDialog'; import DeleteDialog from './components/DeleteDialog';
import MemberManageDialog from './components/MemberManageDialog'; import MemberManageDialog from './components/MemberManageDialog';
import ApplicationManageDialog from './components/ApplicationManageDialog';
import { getUserList } from '@/pages/System/User/service'; import { getUserList } from '@/pages/System/User/service';
import type { UserResponse } from '@/pages/System/User/types'; import type { UserResponse } from '@/pages/System/User/types';
import { getApplicationList } from '@/pages/Deploy/Application/List/service'; import { getEnvironmentList, getApplicationList } from './service';
import type { Application } from '@/pages/Deploy/Application/List/types'; import type { Environment, Application } from './types';
import TeamConfigSheet from './components/TeamConfigSheet';
import { import {
Table, Table,
TableHeader, TableHeader,
@ -50,7 +50,7 @@ import {
Activity, Activity,
Server, Server,
Database, Database,
Package, Settings,
} from 'lucide-react'; } from 'lucide-react';
type Column = { type Column = {
@ -73,8 +73,9 @@ const TeamList: React.FC = () => {
const [currentTeam, setCurrentTeam] = useState<TeamResponse | undefined>(); const [currentTeam, setCurrentTeam] = useState<TeamResponse | undefined>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [memberDialogOpen, setMemberDialogOpen] = useState(false); const [memberDialogOpen, setMemberDialogOpen] = useState(false);
const [applicationDialogOpen, setApplicationDialogOpen] = useState(false); const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [users, setUsers] = useState<UserResponse[]>([]); const [users, setUsers] = useState<UserResponse[]>([]);
const [environments, setEnvironments] = useState<Environment[]>([]);
const [applications, setApplications] = useState<Application[]>([]); const [applications, setApplications] = useState<Application[]>([]);
const form = useForm<SearchFormValues>({ const form = useForm<SearchFormValues>({
@ -119,9 +120,10 @@ const TeamList: React.FC = () => {
} }
}; };
// 加载用户列表和应用列表(仅一次) // 加载基础数据(仅一次)
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
loadEnvironments();
loadApplications(); loadApplications();
}, []); }, []);
@ -136,6 +138,17 @@ const TeamList: React.FC = () => {
} }
}; };
const loadEnvironments = async () => {
try {
const response = await getEnvironmentList();
if (response) {
setEnvironments(response);
}
} catch (error) {
console.error('加载环境列表失败:', error);
}
};
const loadApplications = async () => { const loadApplications = async () => {
try { try {
const response = await getApplicationList(); const response = await getApplicationList();
@ -175,9 +188,9 @@ const TeamList: React.FC = () => {
setMemberDialogOpen(true); setMemberDialogOpen(true);
}; };
const handleManageApplications = (record: TeamResponse) => { const handleConfig = (record: TeamResponse) => {
setCurrentTeam(record); setCurrentTeam(record);
setApplicationDialogOpen(true); setConfigDialogOpen(true);
}; };
const handleModalClose = () => { const handleModalClose = () => {
@ -214,7 +227,7 @@ const TeamList: React.FC = () => {
{ {
id: 'memberCount', id: 'memberCount',
header: '成员数量', header: '成员数量',
size: 120, size: 100,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-center"> <div className="text-center">
<span className="font-medium">{row.original.memberCount || 0}</span> <span className="font-medium">{row.original.memberCount || 0}</span>
@ -222,12 +235,26 @@ const TeamList: React.FC = () => {
), ),
}, },
{ {
id: 'applicationCount', id: 'environmentCount',
header: '应用数量', header: '环境数量',
size: 120, size: 100,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-center"> <div className="text-center">
<span className="font-medium">{row.original.applicationCount || 0}</span> <Badge variant="outline" className="font-medium">
{row.original.environmentCount || 0}
</Badge>
</div>
),
},
{
id: 'applicationCount',
header: '应用数量',
size: 100,
cell: ({ row }) => (
<div className="text-center">
<Badge variant="outline" className="font-medium">
{row.original.applicationCount || 0}
</Badge>
</div> </div>
), ),
}, },
@ -256,9 +283,9 @@ const TeamList: React.FC = () => {
<Users className="h-4 w-4 mr-1" /> <Users className="h-4 w-4 mr-1" />
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => handleManageApplications(row.original)}> <Button variant="ghost" size="sm" onClick={() => handleConfig(row.original)}>
<Package className="h-4 w-4 mr-1" /> <Settings className="h-4 w-4 mr-1" />
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(row.original)}> <Button variant="ghost" size="sm" onClick={() => handleEdit(row.original)}>
<Edit className="h-4 w-4 mr-1" /> <Edit className="h-4 w-4 mr-1" />
@ -368,7 +395,7 @@ const TeamList: React.FC = () => {
{/* 数据表格 */} {/* 数据表格 */}
<div className="rounded-md border"> <div className="rounded-md border">
<Table minWidth="1460px"> <Table minWidth="1440px">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{columns.map((column) => ( {columns.map((column) => (
@ -455,12 +482,14 @@ const TeamList: React.FC = () => {
onOpenChange={setMemberDialogOpen} onOpenChange={setMemberDialogOpen}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
<ApplicationManageDialog <TeamConfigSheet
open={applicationDialogOpen} open={configDialogOpen}
teamId={currentTeam.id} teamId={currentTeam.id}
teamName={currentTeam.teamName} teamName={currentTeam.teamName}
users={users}
environments={environments}
applications={applications} applications={applications}
onOpenChange={setApplicationDialogOpen} onOpenChange={setConfigDialogOpen}
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
</> </>

View File

@ -4,9 +4,15 @@ import type {
TeamQuery, TeamQuery,
TeamResponse, TeamResponse,
TeamRequest, TeamRequest,
TeamConfig,
TeamConfigRequest,
TeamApplication,
TeamApplicationRequest,
} from './types'; } from './types';
const BASE_URL = '/api/v1/teams'; const BASE_URL = '/api/v1/teams';
const TEAM_CONFIG_URL = '/api/v1/team-configs';
const TEAM_APPLICATION_URL = '/api/v1/team-applications';
/** /**
* *
@ -44,3 +50,61 @@ export const updateTeam = (id: number, data: TeamRequest) =>
export const deleteTeam = (id: number) => export const deleteTeam = (id: number) =>
request.delete(`${BASE_URL}/${id}`); request.delete(`${BASE_URL}/${id}`);
// ==================== 环境和应用(复用已有接口) ====================
/**
*
*/
export { getEnvironmentList } from '@/pages/Deploy/Environment/List/service';
/**
*
*/
export { getApplicationListByCondition as getApplicationList } from '@/pages/Deploy/Application/List/service';
// ==================== 团队配置相关 ====================
/**
*
*/
export const getTeamConfig = (teamId: number) =>
request.get<TeamConfig>(`${TEAM_CONFIG_URL}/team/${teamId}`);
/**
*
*/
export const createTeamConfig = (data: TeamConfigRequest) =>
request.post<TeamConfig>(TEAM_CONFIG_URL, data);
/**
*
*/
export const updateTeamConfig = (id: number, data: TeamConfigRequest) =>
request.put<TeamConfig>(`${TEAM_CONFIG_URL}/${id}`, data);
// ==================== 团队应用关联相关 ====================
/**
*
*/
export const getTeamApplications = (params?: { teamId?: number; environmentId?: number; pageNum?: number; size?: number }) =>
request.get<Page<TeamApplication>>(`${TEAM_APPLICATION_URL}/page`, { params });
/**
*
*/
export const createTeamApplication = (data: TeamApplicationRequest) =>
request.post<TeamApplication>(TEAM_APPLICATION_URL, data);
/**
*
*/
export const updateTeamApplication = (id: number, data: TeamApplicationRequest) =>
request.put<TeamApplication>(`${TEAM_APPLICATION_URL}/${id}`, data);
/**
*
*/
export const deleteTeamApplication = (id: number) =>
request.delete(`${TEAM_APPLICATION_URL}/${id}`);

View File

@ -1,4 +1,9 @@
import type { BaseQuery, BaseResponse } from '@/types/base'; import type { BaseQuery, BaseResponse } from '@/types/base';
import type { Environment } from '@/pages/Deploy/Environment/List/types';
import type { Application } from '@/pages/Deploy/Application/List/types';
// 导出环境和应用类型供使用
export type { Environment, Application };
/** /**
* *
@ -21,6 +26,7 @@ export interface TeamResponse extends BaseResponse {
enabled: boolean; enabled: boolean;
sort: number; sort: number;
memberCount?: number; memberCount?: number;
environmentCount?: number;
applicationCount?: number; applicationCount?: number;
} }
@ -37,3 +43,51 @@ export interface TeamRequest {
sort?: number; sort?: number;
} }
// ==================== 团队配置相关 ====================
/**
*
*/
export interface TeamConfig extends BaseResponse {
teamId: number;
allowedEnvironmentIds: number[]; // [1, 2, 3]
environmentApprovalRequired: boolean[]; // [false, false, true]
approverUserIds: (number[] | null)[]; // [null, null, [1, 4]]
}
/**
*
*/
export interface TeamConfigRequest {
teamId: number;
allowedEnvironmentIds: number[]; // [1, 2, 3]
environmentApprovalRequired: boolean[]; // [false, false, true]
approverUserIds: (number[] | null)[]; // [null, null, [1, 4]]
}
// ==================== 团队应用关联相关 ====================
/**
*
*/
export interface TeamApplication extends BaseResponse {
teamId: number;
applicationId: number;
environmentId: number;
branch?: string;
teamName?: string;
applicationName?: string;
applicationCode?: string;
environmentName?: string;
}
/**
*
*/
export interface TeamApplicationRequest {
teamId: number;
applicationId: number;
environmentId: number;
branch?: string;
}