完成流水线控制器重构和相关功能改进

This commit is contained in:
2025-11-21 23:30:05 +08:00
parent fd0cf782c4
commit f8697b87e1
33 changed files with 1262 additions and 538 deletions

View File

@@ -11,21 +11,21 @@
"preview": "rsbuild preview"
},
"dependencies": {
"@arco-design/web-react": "^2.66.4",
"@arco-design/web-react": "^2.66.8",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"axios": "^1.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.8.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@arco-plugins/unplugin-react": "2.0.0-beta.5",
"@rsbuild/core": "^1.4.13",
"@rsbuild/plugin-less": "^1.4.0",
"@rsbuild/plugin-react": "^1.3.4",
"@rsbuild/core": "^1.6.7",
"@rsbuild/plugin-less": "^1.5.0",
"@rsbuild/plugin-react": "^1.4.2",
"@rsbuild/plugin-svgr": "^1.2.2",
"@tailwindcss/postcss": "^4.1.11",
"@types/react": "^18.3.24",

View File

@@ -2,4 +2,4 @@ export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
};

View File

@@ -1,7 +1,7 @@
import { ArcoDesignPlugin } from '@arco-plugins/unplugin-react';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginLess } from '@rsbuild/plugin-less';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginSvgr } from '@rsbuild/plugin-svgr';
export default defineConfig({

View File

@@ -1,10 +1,18 @@
import React, { useEffect } from 'react';
import type React from 'react';
import { useEffect, useCallback } from 'react';
export function useAsyncEffect(
effect: () => Promise<void>,
effect: () => Promise<void | (() => void)>,
deps: React.DependencyList,
) {
const callback = useCallback(effect, [...deps]);
useEffect(() => {
effect();
}, [...deps]);
const cleanupPromise = callback();
return () => {
if (cleanupPromise instanceof Promise) {
cleanupPromise.then(cleanup => cleanup && cleanup());
}
};
}, [callback]);
}

View File

@@ -1,5 +1,5 @@
import ReactDOM from 'react-dom/client';
import App from '@pages/App';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { useGlobalStore } from './stores/global';

View File

@@ -1,9 +1,9 @@
import { Route, Routes, Navigate } from 'react-router';
import Env from '@pages/env';
import Home from '@pages/home';
import Login from '@pages/login';
import ProjectList from '@pages/project/list';
import ProjectDetail from '@pages/project/detail';
import Env from '@pages/env';
import ProjectList from '@pages/project/list';
import { Navigate, Route, Routes } from 'react-router';
import '@styles/index.css';
const App = () => {

View File

@@ -1,12 +1,5 @@
import { useState } from "react";
function Env() {
const [env, setEnv] = useState([]);
return (
<div>
env page
</div>
)
return <div>env page</div>;
}
export default Env;

View File

@@ -6,11 +6,11 @@ import {
IconMenuUnfold,
IconRobot,
} from '@arco-design/web-react/icon';
import { useState } from 'react';
import Logo from '@assets/images/logo.svg?react';
import { loginService } from '@pages/login/service';
import { useState } from 'react';
import { Link, Outlet } from 'react-router';
import { useGlobalStore } from '../../stores/global';
import { loginService } from '@pages/login/service';
function Home() {
const [collapsed, setCollapsed] = useState(false);

View File

@@ -1,11 +1,11 @@
import { Button } from '@arco-design/web-react';
import Gitea from '@assets/images/gitea.svg?react';
import { loginService } from './service';
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { loginService } from './service';
function Login() {
const [ searchParams ] = useSearchParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get('code');

View File

@@ -1,8 +1,8 @@
import { net } from '@shared';
import type { AuthURLResponse, AuthLoginResponse } from './types';
import type { NavigateFunction } from 'react-router';
import { Message, Notification } from '@arco-design/web-react';
import { net } from '@shared';
import type { NavigateFunction } from 'react-router';
import { useGlobalStore } from '../../stores/global';
import type { AuthLoginResponse, AuthURLResponse } from './types';
class LoginService {
async getAuthUrl() {

View File

@@ -1,4 +1,4 @@
import { List, Tag, Space } from '@arco-design/web-react';
import { List, Space, Tag } from '@arco-design/web-react';
// 部署记录类型定义
interface DeployRecord {
@@ -76,9 +76,7 @@ function DeployRecordItem({
<Space size="medium" wrap>
<span className="text-sm text-gray-500">
:{' '}
<span className="font-medium text-gray-700">
{item.branch}
</span>
<span className="font-medium text-gray-700">{item.branch}</span>
</span>
<span className="text-sm text-gray-500">
: {getEnvTag(item.env)}

View File

@@ -1,23 +1,35 @@
import { Typography, Tag, Switch, Button } from '@arco-design/web-react';
import { IconDragArrow, IconEdit, IconDelete } from '@arco-design/web-react/icon';
import { Button, Switch, Tag, Typography } from '@arco-design/web-react';
import {
IconDelete,
IconDragArrow,
IconEdit,
} from '@arco-design/web-react/icon';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// 流水线步骤类型定义
// 流水线步骤类型定义(更新为与后端一致)
interface PipelineStep {
id: string;
id: number;
name: string;
script: string;
description?: string;
order: number;
script: string; // 执行的脚本命令
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
pipelineId: number;
enabled: boolean;
}
interface PipelineStepItemProps {
step: PipelineStep;
index: number;
pipelineId: string;
onToggle: (pipelineId: string, stepId: string, enabled: boolean) => void;
onEdit: (pipelineId: string, step: PipelineStep) => void;
onDelete: (pipelineId: string, stepId: string) => void;
pipelineId: number;
onToggle: (pipelineId: number, stepId: number, enabled: boolean) => void;
onEdit: (pipelineId: number, step: PipelineStep) => void;
onDelete: (pipelineId: number, stepId: number) => void;
}
function PipelineStepItem({
@@ -79,6 +91,9 @@ function PipelineStepItem({
</Tag>
)}
</div>
{step.description && (
<div className="text-gray-600 text-sm mb-2">{step.description}</div>
)}
<div className="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<pre className="whitespace-pre-wrap break-words">{step.script}</pre>
</div>

View File

@@ -1,50 +1,49 @@
import {
Typography,
Tabs,
Button,
List,
Tag,
Space,
Input,
Card,
Switch,
Modal,
Form,
Message,
Collapse,
Dropdown,
Empty,
Form,
Input,
List,
Menu,
Message,
Modal,
Switch,
Tabs,
Tag,
Typography,
} from '@arco-design/web-react';
import type { Project } from '../types';
import { useState } from 'react';
import { useParams } from 'react-router';
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
import { detailService } from './service';
import {
IconCopy,
IconDelete,
IconEdit,
IconMore,
IconPlayArrow,
IconPlus,
IconEdit,
IconDelete,
IconMore,
IconCopy,
} from '@arco-design/web-react/icon';
import DeployRecordItem from './components/DeployRecordItem';
import PipelineStepItem from './components/PipelineStepItem';
import type { DragEndEvent } from '@dnd-kit/core';
import {
DndContext,
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { useState } from 'react';
import { useParams } from 'react-router';
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
import type { Project } from '../types';
import DeployRecordItem from './components/DeployRecordItem';
import PipelineStepItem from './components/PipelineStepItem';
import { detailService } from './service';
// 部署记录类型定义
interface DeployRecord {
@@ -56,23 +55,35 @@ interface DeployRecord {
createdAt: string;
}
// 流水线步骤类型定义
// 流水线步骤类型定义(更新为与后端一致)
interface PipelineStep {
id: string;
id: number;
name: string;
script: string;
description?: string;
order: number;
script: string; // 执行的脚本命令
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
pipelineId: number;
enabled: boolean;
}
// 流水线类型定义
interface Pipeline {
id: string;
id: number;
name: string;
description: string;
enabled: boolean;
steps: PipelineStep[];
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
projectId?: number;
steps?: PipelineStep[];
enabled: boolean;
}
function ProjectDetailPage() {
@@ -83,65 +94,21 @@ function ProjectDetailPage() {
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const [selectedRecordId, setSelectedRecordId] = useState<number>(1);
const [pipelines, setPipelines] = useState<Pipeline[]>([
{
id: 'pipeline1',
name: '前端部署流水线',
description: '用于前端项目的构建和部署',
enabled: true,
createdAt: '2024-09-07 10:00:00',
updatedAt: '2024-09-07 14:30:00',
steps: [
{ id: 'step1', name: '安装依赖', script: 'npm install', enabled: true },
{ id: 'step2', name: '运行测试', script: 'npm test', enabled: true },
{
id: 'step3',
name: '构建项目',
script: 'npm run build',
enabled: true,
},
],
},
{
id: 'pipeline2',
name: 'Docker部署流水线',
description: '用于容器化部署的流水线',
enabled: true,
createdAt: '2024-09-06 16:20:00',
updatedAt: '2024-09-07 09:15:00',
steps: [
{ id: 'step1', name: '安装依赖', script: 'npm install', enabled: true },
{
id: 'step2',
name: '构建镜像',
script: 'docker build -t $PROJECT_NAME:$BUILD_NUMBER .',
enabled: true,
},
{
id: 'step3',
name: 'K8s部署',
script: 'kubectl apply -f deployment.yaml',
enabled: true,
},
],
},
]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedPipelineId, setSelectedPipelineId] = useState<string>(
pipelines.length > 0 ? pipelines[0].id : '',
);
const [selectedPipelineId, setSelectedPipelineId] = useState<number>(0);
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
const [editingPipelineId, setEditingPipelineId] = useState<string | null>(
const [editingPipelineId, setEditingPipelineId] = useState<number | null>(
null,
);
const [pipelineModalVisible, setPipelineModalVisible] = useState(false);
const [editingPipeline, setEditingPipeline] = useState<Pipeline | null>(null);
const [form] = Form.useForm();
const [pipelineForm] = Form.useForm();
const [deployRecords, setDeployRecords] = useState<DeployRecord[]>([
const [deployRecords, _setDeployRecords] = useState<DeployRecord[]>([
{
id: 1,
branch: 'main',
@@ -158,14 +125,7 @@ function ProjectDetailPage() {
status: 'running',
createdAt: '2024-09-07 13:45:12',
},
{
id: 3,
branch: 'feature/user-auth',
env: 'development',
commit: '3a7d9f2b1',
status: 'failed',
createdAt: '2024-09-07 12:20:45',
},
// 移除了 ID 为 3 的部署记录,避免可能的冲突
{
id: 4,
branch: 'main',
@@ -181,6 +141,29 @@ function ProjectDetailPage() {
if (id) {
const project = await detailService.getProject(id);
setDetail(project);
// 获取项目的所有流水线
try {
const pipelineData = await detailService.getPipelines(Number(id));
// 转换数据结构添加enabled字段
const transformedPipelines = pipelineData.map((pipeline) => ({
...pipeline,
description: pipeline.description || '', // 确保description不为undefined
enabled: pipeline.valid === 1, // 根据valid字段设置enabled
steps:
pipeline.steps?.map((step) => ({
...step,
enabled: step.valid === 1, // 根据valid字段设置enabled
})) || [],
}));
setPipelines(transformedPipelines);
if (transformedPipelines.length > 0) {
setSelectedPipelineId(transformedPipelines[0].id);
}
} catch (error) {
console.error('获取流水线数据失败:', error);
Message.error('获取流水线数据失败');
}
}
}, []);
@@ -211,16 +194,7 @@ function ProjectDetailPage() {
'[2024-09-07 13:47:05] 构建镜像: docker build -t app:develop .',
'[2024-09-07 13:48:20] 🔄 正在推送镜像...',
],
3: [
'[2024-09-07 12:20:45] 开始构建...',
'[2024-09-07 12:20:46] 拉取代码: git clone https://github.com/user/repo.git',
'[2024-09-07 12:20:48] 切换分支: git checkout feature/user-auth',
'[2024-09-07 12:20:49] 安装依赖: npm install',
'[2024-09-07 12:21:35] 运行测试: npm test',
'[2024-09-07 12:21:50] ❌ 测试失败',
'[2024-09-07 12:21:51] Error: Authentication test failed',
'[2024-09-07 12:21:51] ❌ 构建失败',
],
// 移除了 ID 为 3 的模拟数据,避免可能的冲突
4: [
'[2024-09-07 10:15:30] 开始构建...',
'[2024-09-07 10:15:31] 拉取代码: git clone https://github.com/user/repo.git',
@@ -256,46 +230,109 @@ function ProjectDetailPage() {
};
// 删除流水线
const handleDeletePipeline = (pipelineId: string) => {
const handleDeletePipeline = async (pipelineId: number) => {
Modal.confirm({
title: '确认删除',
content:
'确定要删除这个流水线吗?此操作不可撤销,将同时删除该流水线下的所有步骤。',
onOk: () => {
setPipelines((prev) => {
const newPipelines = prev.filter((pipeline) => pipeline.id !== pipelineId);
// 如果删除的是当前选中的流水线,选中第一个或清空选择
if (selectedPipelineId === pipelineId) {
setSelectedPipelineId(newPipelines.length > 0 ? newPipelines[0].id : '');
}
return newPipelines;
});
Message.success('流水线删除成功');
onOk: async () => {
try {
// 从数据库删除流水线
await detailService.deletePipeline(pipelineId);
// 更新本地状态
setPipelines((prev) => {
const newPipelines = prev.filter(
(pipeline) => pipeline.id !== pipelineId,
);
// 如果删除的是当前选中的流水线,选中第一个或清空选择
if (selectedPipelineId === pipelineId) {
setSelectedPipelineId(
newPipelines.length > 0 ? newPipelines[0].id : 0,
);
}
return newPipelines;
});
Message.success('流水线删除成功');
} catch (error) {
console.error('删除流水线失败:', error);
Message.error('删除流水线失败');
}
},
});
};
// 复制流水线
const handleCopyPipeline = (pipeline: Pipeline) => {
const newPipeline: Pipeline = {
...pipeline,
id: `pipeline_${Date.now()}`,
name: `${pipeline.name} - 副本`,
createdAt: new Date().toLocaleString(),
updatedAt: new Date().toLocaleString(),
steps: pipeline.steps.map((step) => ({
...step,
id: `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
})),
};
setPipelines((prev) => [...prev, newPipeline]);
// 自动选中新复制的流水线
setSelectedPipelineId(newPipeline.id);
Message.success('流水线复制成功');
const handleCopyPipeline = async (pipeline: Pipeline) => {
Modal.confirm({
title: '确认复制',
content: '确定要复制这个流水线吗?',
onOk: async () => {
try {
// 创建新的流水线
const newPipelineData = await detailService.createPipeline({
name: `${pipeline.name} - 副本`,
description: pipeline.description || '',
projectId: pipeline.projectId,
});
// 复制步骤
if (pipeline.steps && pipeline.steps.length > 0) {
for (const step of pipeline.steps) {
await detailService.createStep({
name: step.name,
description: step.description,
order: step.order,
script: step.script,
pipelineId: newPipelineData.id,
});
}
// 重新获取流水线数据以确保步骤已创建
if (pipeline.projectId) {
const pipelineData = await detailService.getPipelines(
pipeline.projectId,
);
// 转换数据结构添加enabled字段
const transformedPipelines = pipelineData.map((p) => ({
...p,
description: p.description || '', // 确保description不为undefined
enabled: p.valid === 1, // 根据valid字段设置enabled
steps:
p.steps?.map((step) => ({
...step,
enabled: step.valid === 1, // 根据valid字段设置enabled
})) || [],
}));
setPipelines(transformedPipelines);
setSelectedPipelineId(newPipelineData.id);
}
} else {
// 如果没有步骤,直接更新状态
setPipelines((prev) => [
...prev,
{
...newPipelineData,
description: newPipelineData.description || '',
enabled: newPipelineData.valid === 1,
steps: [],
},
]);
setSelectedPipelineId(newPipelineData.id);
}
Message.success('流水线复制成功');
} catch (error) {
console.error('复制流水线失败:', error);
Message.error('复制流水线失败');
}
},
});
};
// 切换流水线启用状态
const handleTogglePipeline = (pipelineId: string, enabled: boolean) => {
const handleTogglePipeline = async (pipelineId: number, enabled: boolean) => {
// 在数据库中更新流水线状态这里简化处理实际可能需要添加enabled字段到数据库
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === pipelineId ? { ...pipeline, enabled } : pipeline,
@@ -308,42 +345,58 @@ function ProjectDetailPage() {
try {
const values = await pipelineForm.validate();
if (editingPipeline) {
setPipelines((prev) => [
...prev.map((pipeline) =>
// 更新现有流水线
const updatedPipeline = await detailService.updatePipeline(
editingPipeline.id,
{
name: values.name,
description: values.description,
},
);
// 更新本地状态
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === editingPipeline.id
? {
...pipeline,
name: values.name,
description: values.description,
updatedAt: new Date().toLocaleString(),
...updatedPipeline,
description: updatedPipeline.description || '',
enabled: updatedPipeline.valid === 1,
steps: pipeline.steps || [], // 保持步骤不变
}
: pipeline,
),
]);
);
Message.success('流水线更新成功');
} else {
const newPipeline: Pipeline = {
id: `pipeline_${Date.now()}`,
// 创建新流水线
const newPipeline = await detailService.createPipeline({
name: values.name,
description: values.description,
enabled: true,
description: values.description || '',
projectId: Number(id),
});
// 更新本地状态
const pipelineWithDefaults = {
...newPipeline,
description: newPipeline.description || '',
enabled: newPipeline.valid === 1,
steps: [],
createdAt: new Date().toLocaleString(),
updatedAt: new Date().toLocaleString(),
};
setPipelines((prev) => [...prev, newPipeline]);
setPipelines((prev) => [...prev, pipelineWithDefaults]);
// 自动选中新创建的流水线
setSelectedPipelineId(newPipeline.id);
Message.success('流水线创建成功');
}
setPipelineModalVisible(false);
} catch (error) {
console.error('表单验证失败:', error);
console.error('保存流水线失败:', error);
Message.error('保存流水线失败');
}
};
// 添加新步骤
const handleAddStep = (pipelineId: string) => {
const handleAddStep = (pipelineId: number) => {
setEditingStep(null);
setEditingPipelineId(pipelineId);
form.resetFields();
@@ -351,7 +404,7 @@ function ProjectDetailPage() {
};
// 编辑步骤
const handleEditStep = (pipelineId: string, step: PipelineStep) => {
const handleEditStep = (pipelineId: number, step: PipelineStep) => {
setEditingStep(step);
setEditingPipelineId(pipelineId);
form.setFieldsValue({
@@ -362,40 +415,55 @@ function ProjectDetailPage() {
};
// 删除步骤
const handleDeleteStep = (pipelineId: string, stepId: string) => {
const handleDeleteStep = async (pipelineId: number, stepId: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个流水线步骤吗?此操作不可撤销。',
onOk: () => {
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === pipelineId
? {
...pipeline,
steps: pipeline.steps.filter((step) => step.id !== stepId),
}
: pipeline,
),
);
Message.success('步骤删除成功');
onOk: async () => {
try {
// 从数据库删除步骤
await detailService.deleteStep(stepId);
// 更新本地状态
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === pipelineId
? {
...pipeline,
steps:
pipeline.steps?.filter((step) => step.id !== stepId) ||
[],
updatedAt: new Date().toISOString(),
}
: pipeline,
),
);
Message.success('步骤删除成功');
} catch (error) {
console.error('删除步骤失败:', error);
Message.error('删除步骤失败');
}
},
});
};
// 切换步骤启用状态
const handleToggleStep = (
pipelineId: string,
stepId: string,
const handleToggleStep = async (
pipelineId: number,
stepId: number,
enabled: boolean,
) => {
// 在数据库中更新步骤状态这里简化处理实际可能需要添加enabled字段到数据库
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === pipelineId
? {
...pipeline,
steps: pipeline.steps.map((step) =>
step.id === stepId ? { ...step, enabled } : step,
),
steps:
pipeline.steps?.map((step) =>
step.id === stepId ? { ...step, enabled } : step,
) || [],
updatedAt: new Date().toISOString(),
}
: pipeline,
),
@@ -403,7 +471,7 @@ function ProjectDetailPage() {
};
// 拖拽结束处理
const handleDragEnd = (event: DragEndEvent) => {
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
@@ -411,20 +479,25 @@ function ProjectDetailPage() {
}
if (selectedPipelineId) {
// 更新步骤顺序到数据库简化处理实际应该更新所有步骤的order字段
setPipelines((prev) =>
prev.map((pipeline) => {
if (pipeline.id === selectedPipelineId) {
const oldIndex = pipeline.steps.findIndex((step) => step.id === active.id);
const newIndex = pipeline.steps.findIndex((step) => step.id === over.id);
const oldIndex =
pipeline.steps?.findIndex((step) => step.id === active.id) || 0;
const newIndex =
pipeline.steps?.findIndex((step) => step.id === over.id) || 0;
return {
...pipeline,
steps: arrayMove(pipeline.steps, oldIndex, newIndex),
updatedAt: new Date().toLocaleString(),
steps: pipeline.steps
? arrayMove(pipeline.steps, oldIndex, newIndex)
: [],
updatedAt: new Date().toISOString(),
};
}
return pipeline;
})
}),
);
Message.success('步骤顺序调整成功');
}
@@ -435,36 +508,52 @@ function ProjectDetailPage() {
try {
const values = await form.validate();
if (editingStep && editingPipelineId) {
// 更新现有步骤
const updatedStep = await detailService.updateStep(editingStep.id, {
name: values.name,
script: values.script,
});
// 更新本地状态
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === editingPipelineId
? {
...pipeline,
steps: pipeline.steps.map((step) =>
step.id === editingStep.id
? { ...step, name: values.name, script: values.script }
: step,
),
updatedAt: new Date().toLocaleString(),
steps:
pipeline.steps?.map((step) =>
step.id === editingStep.id
? { ...updatedStep, enabled: step.enabled }
: step,
) || [],
updatedAt: new Date().toISOString(),
}
: pipeline,
),
);
Message.success('步骤更新成功');
} else if (editingPipelineId) {
const newStep: PipelineStep = {
id: `step_${Date.now()}`,
// 创建新步骤
const newStep = await detailService.createStep({
name: values.name,
script: values.script,
enabled: true,
};
order:
pipelines.find((p) => p.id === editingPipelineId)?.steps?.length ||
0,
pipelineId: editingPipelineId,
});
// 更新本地状态
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === editingPipelineId
? {
...pipeline,
steps: [...pipeline.steps, newStep],
updatedAt: new Date().toLocaleString(),
steps: [
...(pipeline.steps || []),
{ ...newStep, enabled: true },
],
updatedAt: new Date().toISOString(),
}
: pipeline,
),
@@ -473,7 +562,8 @@ function ProjectDetailPage() {
}
setEditModalVisible(false);
} catch (error) {
console.error('表单验证失败:', error);
console.error('保存步骤失败:', error);
Message.error('保存步骤失败');
}
};
@@ -498,7 +588,7 @@ function ProjectDetailPage() {
};
// 渲染部署记录项
const renderDeployRecordItem = (item: DeployRecord, index: number) => {
const renderDeployRecordItem = (item: DeployRecord, _index: number) => {
const isSelected = item.id === selectedRecordId;
return (
<DeployRecordItem
@@ -538,12 +628,18 @@ function ProjectDetailPage() {
</Button>
</div>
<div className="h-full overflow-y-auto">
<List
className="bg-white rounded-lg border"
dataSource={deployRecords}
render={renderDeployRecordItem}
split={true}
/>
{deployRecords.length > 0 ? (
<List
className="bg-white rounded-lg border"
dataSource={deployRecords}
render={renderDeployRecordItem}
split={true}
/>
) : (
<div className="text-center py-12">
<Empty description="暂无部署记录" />
</div>
)}
</div>
</div>
@@ -621,7 +717,9 @@ function ProjectDetailPage() {
<Typography.Title
heading={6}
className={`!m-0 ${
isSelected ? 'text-blue-600' : 'text-gray-900'
isSelected
? 'text-blue-600'
: 'text-gray-900'
}`}
>
{pipeline.name}
@@ -647,14 +745,18 @@ function ProjectDetailPage() {
<Menu>
<Menu.Item
key="edit"
onClick={() => handleEditPipeline(pipeline)}
onClick={() =>
handleEditPipeline(pipeline)
}
>
<IconEdit className="mr-2" />
线
</Menu.Item>
<Menu.Item
key="copy"
onClick={() => handleCopyPipeline(pipeline)}
onClick={() =>
handleCopyPipeline(pipeline)
}
>
<IconCopy className="mr-2" />
线
@@ -684,8 +786,14 @@ function ProjectDetailPage() {
<div className="text-sm text-gray-500">
<div>{pipeline.description}</div>
<div className="flex items-center justify-between mt-2">
<span> {pipeline.steps.length} </span>
<span>{pipeline.updatedAt}</span>
<span>
{pipeline.steps?.length || 0}
</span>
<span>
{new Date(
pipeline.updatedAt,
).toLocaleString()}
</span>
</div>
</div>
</div>
@@ -695,8 +803,9 @@ function ProjectDetailPage() {
{pipelines.length === 0 && (
<div className="text-center py-12">
<Empty description="暂无流水线" />
<Typography.Text type="secondary">
线"新建流水线"
"新建流水线"
</Typography.Text>
</div>
)}
@@ -706,9 +815,12 @@ function ProjectDetailPage() {
{/* 右侧流水线步骤详情 */}
<div className="col-span-3 bg-white rounded-lg border h-full overflow-hidden">
{selectedPipelineId && pipelines.find(p => p.id === selectedPipelineId) ? (
{selectedPipelineId &&
pipelines.find((p) => p.id === selectedPipelineId) ? (
(() => {
const selectedPipeline = pipelines.find(p => p.id === selectedPipelineId)!;
const selectedPipeline = pipelines.find(
(p) => p.id === selectedPipelineId,
);
return (
<>
<div className="p-4 border-b bg-gray-50">
@@ -717,8 +829,12 @@ function ProjectDetailPage() {
<Typography.Title heading={5} className="!m-0">
{selectedPipeline.name} - 线
</Typography.Title>
<Typography.Text type="secondary" className="text-sm">
{selectedPipeline.description} · {selectedPipeline.steps.length}
<Typography.Text
type="secondary"
className="text-sm"
>
{selectedPipeline.description} · {' '}
{selectedPipeline.steps?.length || 0}
</Typography.Text>
</div>
<Button
@@ -738,11 +854,15 @@ function ProjectDetailPage() {
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedPipeline.steps.map(step => step.id)}
items={
selectedPipeline.steps?.map(
(step) => step.id,
) || []
}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{selectedPipeline.steps.map((step, index) => (
{selectedPipeline.steps?.map((step, index) => (
<PipelineStepItem
key={step.id}
step={step}
@@ -754,10 +874,11 @@ function ProjectDetailPage() {
/>
))}
{selectedPipeline.steps.length === 0 && (
{selectedPipeline.steps?.length === 0 && (
<div className="text-center py-12">
<Empty description="暂无步骤" />
<Typography.Text type="secondary">
"添加步骤"
"添加步骤"
</Typography.Text>
</div>
)}
@@ -770,9 +891,7 @@ function ProjectDetailPage() {
})()
) : (
<div className="flex items-center justify-center h-full">
<Typography.Text type="secondary">
线
</Typography.Text>
<Empty description="请选择流水线" />
</div>
)}
</div>

View File

@@ -1,13 +1,125 @@
import { net, type APIResponse } from '@shared';
import type { Project } from '../types';
import { type APIResponse, net } from '@shared';
import type { Pipeline, Project, Step } from '../types';
class DetailService {
async getProject(id: string) {
const { code, data } = await net.request<APIResponse<Project>>({
const { data } = await net.request<APIResponse<Project>>({
url: `/api/projects/${id}`,
});
return data;
}
// 获取项目的所有流水线
async getPipelines(projectId: number) {
const { data } = await net.request<APIResponse<Pipeline[]>>({
url: `/api/pipelines?projectId=${projectId}`,
});
return data;
}
// 创建流水线
async createPipeline(
pipeline: Omit<
Pipeline,
| 'id'
| 'createdAt'
| 'updatedAt'
| 'createdBy'
| 'updatedBy'
| 'valid'
| 'steps'
>,
) {
const { data } = await net.request<APIResponse<Pipeline>>({
url: '/api/pipelines',
method: 'POST',
data: pipeline,
});
return data;
}
// 更新流水线
async updatePipeline(
id: number,
pipeline: Partial<
Omit<
Pipeline,
| 'id'
| 'createdAt'
| 'updatedAt'
| 'createdBy'
| 'updatedBy'
| 'valid'
| 'steps'
>
>,
) {
const { data } = await net.request<APIResponse<Pipeline>>({
url: `/api/pipelines/${id}`,
method: 'PUT',
data: pipeline,
});
return data;
}
// 删除流水线
async deletePipeline(id: number) {
const { data } = await net.request<APIResponse<null>>({
url: `/api/pipelines/${id}`,
method: 'DELETE',
});
return data;
}
// 获取流水线的所有步骤
async getSteps(pipelineId: number) {
const { data } = await net.request<APIResponse<Step[]>>({
url: `/api/steps?pipelineId=${pipelineId}`,
});
return data;
}
// 创建步骤
async createStep(
step: Omit<
Step,
'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid'
>,
) {
const { data } = await net.request<APIResponse<Step>>({
url: '/api/steps',
method: 'POST',
data: step,
});
return data;
}
// 更新步骤
async updateStep(
id: number,
step: Partial<
Omit<
Step,
'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid'
>
>,
) {
const { data } = await net.request<APIResponse<Step>>({
url: `/api/steps/${id}`,
method: 'PUT',
data: step,
});
return data;
}
// 删除步骤
async deleteStep(id: number) {
const { data } = await net.request<APIResponse<null>>({
url: `/api/steps/${id}`,
method: 'DELETE',
});
return data;
}
}
export const detailService = new DetailService();

View File

@@ -1,7 +1,7 @@
import { Modal, Form, Input, Button, Message } from '@arco-design/web-react';
import React, { useState } from 'react';
import type { Project } from '../types';
import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
import { useState } from 'react';
import { projectService } from '../service';
import type { Project } from '../types';
interface CreateProjectModalProps {
visible: boolean;
@@ -9,7 +9,11 @@ interface CreateProjectModalProps {
onSuccess: (newProject: Project) => void;
}
function CreateProjectModal({ visible, onCancel, onSuccess }: CreateProjectModalProps) {
function CreateProjectModal({
visible,
onCancel,
onSuccess,
}: CreateProjectModalProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
@@ -46,17 +50,18 @@ function CreateProjectModal({ visible, onCancel, onSuccess }: CreateProjectModal
<Button key="cancel" onClick={handleCancel}>
</Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleSubmit}>
<Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
>
</Button>,
]}
style={{ width: 500 }}
>
<Form
form={form}
layout="vertical"
autoComplete="off"
>
<Form form={form} layout="vertical" autoComplete="off">
<Form.Item
label="项目名称"
field="name"
@@ -71,9 +76,7 @@ function CreateProjectModal({ visible, onCancel, onSuccess }: CreateProjectModal
<Form.Item
label="项目描述"
field="description"
rules={[
{ maxLength: 200, message: '项目描述不能超过200个字符' },
]}
rules={[{ maxLength: 200, message: '项目描述不能超过200个字符' }]}
>
<Input.TextArea
placeholder="请输入项目描述(可选)"

View File

@@ -1,7 +1,7 @@
import { Modal, Form, Input, Button, Message } from '@arco-design/web-react';
import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
import React, { useState } from 'react';
import type { Project } from '../types';
import { projectService } from '../service';
import type { Project } from '../types';
interface EditProjectModalProps {
visible: boolean;
@@ -10,7 +10,12 @@ interface EditProjectModalProps {
onSuccess: (updatedProject: Project) => void;
}
function EditProjectModal({ visible, project, onCancel, onSuccess }: EditProjectModalProps) {
function EditProjectModal({
visible,
project,
onCancel,
onSuccess,
}: EditProjectModalProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
@@ -59,17 +64,18 @@ function EditProjectModal({ visible, project, onCancel, onSuccess }: EditProject
<Button key="cancel" onClick={handleCancel}>
</Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleSubmit}>
<Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
>
</Button>,
]}
style={{ width: 500 }}
>
<Form
form={form}
layout="vertical"
autoComplete="off"
>
<Form form={form} layout="vertical" autoComplete="off">
<Form.Item
label="项目名称"
field="name"
@@ -84,9 +90,7 @@ function EditProjectModal({ visible, project, onCancel, onSuccess }: EditProject
<Form.Item
label="项目描述"
field="description"
rules={[
{ maxLength: 200, message: '项目描述不能超过200个字符' },
]}
rules={[{ maxLength: 200, message: '项目描述不能超过200个字符' }]}
>
<Input.TextArea
placeholder="请输入项目描述"

View File

@@ -1,27 +1,27 @@
import {
Card,
Tag,
Avatar,
Space,
Typography,
Button,
Tooltip,
Card,
Dropdown,
Menu,
Modal,
Space,
Tag,
Tooltip,
Typography,
} from '@arco-design/web-react';
import {
IconBranch,
IconCalendar,
IconCloud,
IconDelete,
IconEdit,
IconMore,
IconDelete,
} from '@arco-design/web-react/icon';
import type { Project } from '../../types';
import IconGitea from '@assets/images/gitea.svg?react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { Project } from '../../types';
const { Text, Paragraph } = Typography;

View File

@@ -1,12 +1,12 @@
import { Grid, Typography, Button, Message } from '@arco-design/web-react';
import { Button, Grid, Message, Typography } from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon';
import { useState } from 'react';
import type { Project } from '../types';
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
import { projectService } from './service';
import ProjectCard from './components/ProjectCard';
import EditProjectModal from './components/EditProjectModal';
import type { Project } from '../types';
import CreateProjectModal from './components/CreateProjectModal';
import EditProjectModal from './components/EditProjectModal';
import ProjectCard from './components/ProjectCard';
import { projectService } from './service';
const { Text } = Typography;
@@ -27,8 +27,8 @@ function ProjectPage() {
};
const handleEditSuccess = (updatedProject: Project) => {
setProjects(prev =>
prev.map(p => p.id === updatedProject.id ? updatedProject : p)
setProjects((prev) =>
prev.map((p) => (p.id === updatedProject.id ? updatedProject : p)),
);
};
@@ -42,7 +42,7 @@ function ProjectPage() {
};
const handleCreateSuccess = (newProject: Project) => {
setProjects(prev => [newProject, ...prev]);
setProjects((prev) => [newProject, ...prev]);
};
const handleCreateCancel = () => {
@@ -52,7 +52,7 @@ function ProjectPage() {
const handleDeleteProject = async (project: Project) => {
try {
await projectService.delete(project.id);
setProjects(prev => prev.filter(p => p.id !== project.id));
setProjects((prev) => prev.filter((p) => p.id !== project.id));
Message.success('项目删除成功');
} catch (error) {
console.error('删除项目失败:', error);

View File

@@ -1,7 +1,6 @@
import { net, type APIResponse } from '@shared';
import { type APIResponse, net } from '@shared';
import type { Project } from '../types';
class ProjectService {
async list(params?: ProjectQueryParams) {
const { data } = await net.request<APIResponse<ProjectListResponse>>({

View File

@@ -1,7 +1,7 @@
enum BuildStatus {
Idle = "Pending",
Running = "Running",
Stopped = "Stopped",
Idle = 'Pending',
Running = 'Running',
Stopped = 'Stopped',
}
export interface Project {
@@ -16,3 +16,32 @@ export interface Project {
updatedBy: string;
status: BuildStatus;
}
// 流水线步骤类型定义
export interface Step {
id: number;
name: string;
description?: string;
order: number;
script: string; // 执行的脚本命令
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
pipelineId: number;
}
// 流水线类型定义
export interface Pipeline {
id: number;
name: string;
description?: string;
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
projectId?: number;
steps?: Step[];
}

View File

@@ -1,4 +1,4 @@
import axios, { Axios, type AxiosRequestConfig } from 'axios';
import axios, { type Axios, type AxiosRequestConfig } from 'axios';
class Net {
private readonly instance: Axios;
@@ -18,7 +18,7 @@ class Net {
return response;
},
(error) => {
console.log('error', error)
console.log('error', error);
if (error.status === 401 && error.config.url !== '/api/auth/info') {
window.location.href = '/login';
return;
@@ -29,8 +29,16 @@ class Net {
}
async request<T>(config: AxiosRequestConfig): Promise<T> {
const { data } = await this.instance.request<T>(config);
return data;
try {
const response = await this.instance.request<T>(config);
if (!response || !response.data) {
throw new Error('Invalid response');
}
return response.data;
} catch (error) {
console.error('Request failed:', error);
throw error;
}
}
}

View File

@@ -1,4 +1,4 @@
import { net, type APIResponse } from '@shared';
import { type APIResponse, net } from '@shared';
import { create } from 'zustand';
interface User {

View File

@@ -1 +1 @@
@import 'tailwindcss';
@import "tailwindcss";

View File

@@ -20,7 +20,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"paths": {
"paths": {
"@pages/*": ["./src/pages/*"],
"@styles/*": ["./src/styles/*"],
"@assets/*": ["./src/assets/*"],