From ef4fce6d42ba63ec5b20a71beb14cb8811fd78dc Mon Sep 17 00:00:00 2001 From: hurole <1192163814@qq.com> Date: Sun, 7 Sep 2025 22:35:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=9E=B6=E6=9E=84=E5=B9=B6=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E6=AD=A5=E9=AA=A4=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要更新: - 重构项目页面结构:将原有项目页面拆分为 list 和 detail 两个子模块 - 新增项目详情页面,支持多标签页展示(流水线、部署记录) - 实现流水线管理功能:支持新建、编辑、复制、删除、启用/禁用 - 实现流水线步骤管理:支持添加、编辑、删除、启用/禁用步骤 - 新增流水线步骤拖拽排序功能:集成 @dnd-kit 实现拖拽重排 - 优化左右两栏布局:左侧流水线列表,右侧步骤详情 - 新增部署记录展示功能:左右两栏布局,支持选中切换 - 提取可复用组件:DeployRecordItem、PipelineStepItem - 添加表单验证和用户交互反馈 - 更新路由配置支持项目详情页面 技术改进: - 安装 @dnd-kit 相关依赖实现拖拽功能 - 优化 TypeScript 类型定义 - 改进组件化设计,提高代码复用性 - 增强用户体验和交互反馈 --- apps/server/prisma/data/dev.db | Bin 24576 -> 24576 bytes apps/web/package.json | 11 +- apps/web/src/pages/App.tsx | 6 +- .../detail/components/DeployRecordItem.tsx | 103 +++ .../detail/components/PipelineStepItem.tsx | 108 +++ apps/web/src/pages/project/detail/index.tsx | 854 ++++++++++++++++++ apps/web/src/pages/project/detail/service.ts | 13 + .../components/CreateProjectModal.tsx | 0 .../components/EditProjectModal.tsx | 0 .../{ => list}/components/ProjectCard.tsx | 64 +- .../src/pages/project/{ => list}/index.tsx | 4 +- .../src/pages/project/{ => list}/service.ts | 111 +-- package.json | 2 +- pnpm-lock.yaml | 118 ++- 14 files changed, 1272 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/pages/project/detail/components/DeployRecordItem.tsx create mode 100644 apps/web/src/pages/project/detail/components/PipelineStepItem.tsx create mode 100644 apps/web/src/pages/project/detail/index.tsx create mode 100644 apps/web/src/pages/project/detail/service.ts rename apps/web/src/pages/project/{ => list}/components/CreateProjectModal.tsx (100%) rename apps/web/src/pages/project/{ => list}/components/EditProjectModal.tsx (100%) rename apps/web/src/pages/project/{ => list}/components/ProjectCard.tsx (78%) rename apps/web/src/pages/project/{ => list}/index.tsx (96%) rename apps/web/src/pages/project/{ => list}/service.ts (70%) diff --git a/apps/server/prisma/data/dev.db b/apps/server/prisma/data/dev.db index d04979eb9269ee07f85841b500237f85f8e55fbf..811b686c30fd5c6edb9bb010585a8858d426afd8 100644 GIT binary patch delta 36 ncmZoTz}Rqrae_3X*hCp;MzM_v6XFF_tnJ?yR~DC~=0ZpS<+2Sk delta 36 ncmZoTz}Rqrae_3X=tLQ3M$wH46XFHrdD^*(D~n4~b0H)E+P@57 diff --git a/apps/web/package.json b/apps/web/package.json index 83cbb0d..6780c1f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,9 +12,12 @@ }, "dependencies": { "@arco-design/web-react": "^2.66.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "axios": "^1.11.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-router": "^7.8.0", "zustand": "^5.0.8" }, @@ -25,8 +28,8 @@ "@rsbuild/plugin-react": "^1.3.4", "@rsbuild/plugin-svgr": "^1.2.2", "@tailwindcss/postcss": "^4.1.11", - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", "tailwindcss": "^4.1.11", "typescript": "^5.9.2" }, diff --git a/apps/web/src/pages/App.tsx b/apps/web/src/pages/App.tsx index 5bd4d32..8f8ae7a 100644 --- a/apps/web/src/pages/App.tsx +++ b/apps/web/src/pages/App.tsx @@ -1,7 +1,8 @@ import { Route, Routes, Navigate } from 'react-router'; import Home from '@pages/home'; import Login from '@pages/login'; -import Project from '@pages/project'; +import ProjectList from '@pages/project/list'; +import ProjectDetail from '@pages/project/detail'; import Env from '@pages/env'; import '@styles/index.css'; @@ -10,7 +11,8 @@ const App = () => { }> } /> - } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx b/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx new file mode 100644 index 0000000..90c52a9 --- /dev/null +++ b/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx @@ -0,0 +1,103 @@ +import { List, Tag, Space } from '@arco-design/web-react'; + +// 部署记录类型定义 +interface DeployRecord { + id: number; + branch: string; + env: string; + commit: string; + status: 'success' | 'running' | 'failed' | 'pending'; + createdAt: string; +} + +interface DeployRecordItemProps { + item: DeployRecord; + isSelected: boolean; + onSelect: (id: number) => void; +} + +function DeployRecordItem({ + item, + isSelected, + onSelect, +}: DeployRecordItemProps) { + // 状态标签渲染函数 + const getStatusTag = (status: DeployRecord['status']) => { + const statusMap: Record< + DeployRecord['status'], + { color: string; text: string } + > = { + success: { color: 'green', text: '成功' }, + running: { color: 'blue', text: '运行中' }, + failed: { color: 'red', text: '失败' }, + pending: { color: 'orange', text: '等待中' }, + }; + const config = statusMap[status]; + return {config.text}; + }; + + // 环境标签渲染函数 + const getEnvTag = (env: string) => { + const envMap: Record = { + production: { color: 'red', text: '生产环境' }, + staging: { color: 'orange', text: '预发布环境' }, + development: { color: 'blue', text: '开发环境' }, + }; + const config = envMap[env] || { color: 'gray', text: env }; + return {config.text}; + }; + return ( + onSelect(item.id)} + > + + + #{item.id} + + + {item.commit} + + + } + description={ +
+ + + 分支:{' '} + + {item.branch} + + + + 环境: {getEnvTag(item.env)} + + + 状态: {getStatusTag(item.status)} + + + 执行时间:{' '} + + {item.createdAt} + + + +
+ } + /> +
+ ); +} + +export default DeployRecordItem; diff --git a/apps/web/src/pages/project/detail/components/PipelineStepItem.tsx b/apps/web/src/pages/project/detail/components/PipelineStepItem.tsx new file mode 100644 index 0000000..85c05a1 --- /dev/null +++ b/apps/web/src/pages/project/detail/components/PipelineStepItem.tsx @@ -0,0 +1,108 @@ +import { Typography, Tag, Switch, Button } from '@arco-design/web-react'; +import { IconDragArrow, IconEdit, IconDelete } from '@arco-design/web-react/icon'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +// 流水线步骤类型定义 +interface PipelineStep { + id: string; + name: string; + script: string; + 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; +} + +function PipelineStepItem({ + step, + index, + pipelineId, + onToggle, + onEdit, + onDelete, +}: PipelineStepItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: step.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+
+
+ +
+
+ {index + 1} +
+
+ +
+
+ + {step.name} + + onToggle(pipelineId, step.id, enabled)} + /> + {!step.enabled && ( + + 已禁用 + + )} +
+
+
{step.script}
+
+
+ +
+
+
+
+ ); +} + +export default PipelineStepItem; diff --git a/apps/web/src/pages/project/detail/index.tsx b/apps/web/src/pages/project/detail/index.tsx new file mode 100644 index 0000000..5189ce7 --- /dev/null +++ b/apps/web/src/pages/project/detail/index.tsx @@ -0,0 +1,854 @@ +import { + Typography, + Tabs, + Button, + List, + Tag, + Space, + Input, + Card, + Switch, + Modal, + Form, + Message, + Collapse, + Dropdown, + Menu, +} 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 { + IconPlayArrow, + IconPlus, + IconEdit, + IconDelete, + IconMore, + IconCopy, +} from '@arco-design/web-react/icon'; +import DeployRecordItem from './components/DeployRecordItem'; +import PipelineStepItem from './components/PipelineStepItem'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable'; + +// 部署记录类型定义 +interface DeployRecord { + id: number; + branch: string; + env: string; + commit: string; + status: 'success' | 'running' | 'failed' | 'pending'; + createdAt: string; +} + +// 流水线步骤类型定义 +interface PipelineStep { + id: string; + name: string; + script: string; + enabled: boolean; +} + +// 流水线类型定义 +interface Pipeline { + id: string; + name: string; + description: string; + enabled: boolean; + steps: PipelineStep[]; + createdAt: string; + updatedAt: string; +} + +function ProjectDetailPage() { + const [detail, setDetail] = useState(); + + // 拖拽传感器配置 + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + const [selectedRecordId, setSelectedRecordId] = useState(1); + const [pipelines, setPipelines] = useState([ + { + 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 [editModalVisible, setEditModalVisible] = useState(false); + const [selectedPipelineId, setSelectedPipelineId] = useState( + pipelines.length > 0 ? pipelines[0].id : '', + ); + const [editingStep, setEditingStep] = useState(null); + const [editingPipelineId, setEditingPipelineId] = useState( + null, + ); + const [pipelineModalVisible, setPipelineModalVisible] = useState(false); + const [editingPipeline, setEditingPipeline] = useState(null); + const [form] = Form.useForm(); + const [pipelineForm] = Form.useForm(); + const [deployRecords, setDeployRecords] = useState([ + { + id: 1, + branch: 'main', + env: 'development', + commit: '1d1224ae1', + status: 'success', + createdAt: '2024-09-07 14:30:25', + }, + { + id: 2, + branch: 'develop', + env: 'staging', + commit: '2f4b5c8e9', + 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: 4, + branch: 'main', + env: 'production', + commit: '4e8b6a5c3', + status: 'success', + createdAt: '2024-09-07 10:15:30', + }, + ]); + + const { id } = useParams(); + useAsyncEffect(async () => { + if (id) { + const project = await detailService.getProject(id); + setDetail(project); + } + }, []); + + // 获取模拟的构建日志 + const getBuildLogs = (recordId: number): string[] => { + const logs: Record = { + 1: [ + '[2024-09-07 14:30:25] 开始构建...', + '[2024-09-07 14:30:26] 拉取代码: git clone https://github.com/user/repo.git', + '[2024-09-07 14:30:28] 切换分支: git checkout main', + '[2024-09-07 14:30:29] 安装依赖: npm install', + '[2024-09-07 14:31:15] 运行测试: npm test', + '[2024-09-07 14:31:30] ✅ 所有测试通过', + '[2024-09-07 14:31:31] 构建项目: npm run build', + '[2024-09-07 14:32:10] 构建镜像: docker build -t app:latest .', + '[2024-09-07 14:33:25] 推送镜像: docker push registry.com/app:latest', + '[2024-09-07 14:34:10] 部署到开发环境...', + '[2024-09-07 14:34:45] ✅ 部署成功', + ], + 2: [ + '[2024-09-07 13:45:12] 开始构建...', + '[2024-09-07 13:45:13] 拉取代码: git clone https://github.com/user/repo.git', + '[2024-09-07 13:45:15] 切换分支: git checkout develop', + '[2024-09-07 13:45:16] 安装依赖: npm install', + '[2024-09-07 13:46:02] 运行测试: npm test', + '[2024-09-07 13:46:18] ✅ 所有测试通过', + '[2024-09-07 13:46:19] 构建项目: npm run build', + '[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] ❌ 构建失败', + ], + 4: [ + '[2024-09-07 10:15:30] 开始构建...', + '[2024-09-07 10:15:31] 拉取代码: git clone https://github.com/user/repo.git', + '[2024-09-07 10:15:33] 切换分支: git checkout main', + '[2024-09-07 10:15:34] 安装依赖: npm install', + '[2024-09-07 10:16:20] 运行测试: npm test', + '[2024-09-07 10:16:35] ✅ 所有测试通过', + '[2024-09-07 10:16:36] 构建项目: npm run build', + '[2024-09-07 10:17:22] 构建镜像: docker build -t app:v1.0.0 .', + '[2024-09-07 10:18:45] 推送镜像: docker push registry.com/app:v1.0.0', + '[2024-09-07 10:19:30] 部署到生产环境...', + '[2024-09-07 10:20:15] ✅ 部署成功', + ], + }; + return logs[recordId] || ['暂无日志记录']; + }; + + // 添加新流水线 + const handleAddPipeline = () => { + setEditingPipeline(null); + pipelineForm.resetFields(); + setPipelineModalVisible(true); + }; + + // 编辑流水线 + const handleEditPipeline = (pipeline: Pipeline) => { + setEditingPipeline(pipeline); + pipelineForm.setFieldsValue({ + name: pipeline.name, + description: pipeline.description, + }); + setPipelineModalVisible(true); + }; + + // 删除流水线 + const handleDeletePipeline = (pipelineId: string) => { + 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('流水线删除成功'); + }, + }); + }; + + // 复制流水线 + 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 handleTogglePipeline = (pipelineId: string, enabled: boolean) => { + setPipelines((prev) => + prev.map((pipeline) => + pipeline.id === pipelineId ? { ...pipeline, enabled } : pipeline, + ), + ); + }; + + // 保存流水线 + const handleSavePipeline = async () => { + try { + const values = await pipelineForm.validate(); + if (editingPipeline) { + setPipelines((prev) => [ + ...prev.map((pipeline) => + pipeline.id === editingPipeline.id + ? { + ...pipeline, + name: values.name, + description: values.description, + updatedAt: new Date().toLocaleString(), + } + : pipeline, + ), + ]); + Message.success('流水线更新成功'); + } else { + const newPipeline: Pipeline = { + id: `pipeline_${Date.now()}`, + name: values.name, + description: values.description, + enabled: true, + steps: [], + createdAt: new Date().toLocaleString(), + updatedAt: new Date().toLocaleString(), + }; + setPipelines((prev) => [...prev, newPipeline]); + // 自动选中新创建的流水线 + setSelectedPipelineId(newPipeline.id); + Message.success('流水线创建成功'); + } + setPipelineModalVisible(false); + } catch (error) { + console.error('表单验证失败:', error); + } + }; + + // 添加新步骤 + const handleAddStep = (pipelineId: string) => { + setEditingStep(null); + setEditingPipelineId(pipelineId); + form.resetFields(); + setEditModalVisible(true); + }; + + // 编辑步骤 + const handleEditStep = (pipelineId: string, step: PipelineStep) => { + setEditingStep(step); + setEditingPipelineId(pipelineId); + form.setFieldsValue({ + name: step.name, + script: step.script, + }); + setEditModalVisible(true); + }; + + // 删除步骤 + const handleDeleteStep = (pipelineId: string, stepId: string) => { + 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('步骤删除成功'); + }, + }); + }; + + // 切换步骤启用状态 + const handleToggleStep = ( + pipelineId: string, + stepId: string, + enabled: boolean, + ) => { + setPipelines((prev) => + prev.map((pipeline) => + pipeline.id === pipelineId + ? { + ...pipeline, + steps: pipeline.steps.map((step) => + step.id === stepId ? { ...step, enabled } : step, + ), + } + : pipeline, + ), + ); + }; + + // 拖拽结束处理 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + if (selectedPipelineId) { + 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); + + return { + ...pipeline, + steps: arrayMove(pipeline.steps, oldIndex, newIndex), + updatedAt: new Date().toLocaleString(), + }; + } + return pipeline; + }) + ); + Message.success('步骤顺序调整成功'); + } + }; + + // 保存步骤 + const handleSaveStep = async () => { + try { + const values = await form.validate(); + if (editingStep && editingPipelineId) { + 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(), + } + : pipeline, + ), + ); + Message.success('步骤更新成功'); + } else if (editingPipelineId) { + const newStep: PipelineStep = { + id: `step_${Date.now()}`, + name: values.name, + script: values.script, + enabled: true, + }; + setPipelines((prev) => + prev.map((pipeline) => + pipeline.id === editingPipelineId + ? { + ...pipeline, + steps: [...pipeline.steps, newStep], + updatedAt: new Date().toLocaleString(), + } + : pipeline, + ), + ); + Message.success('步骤添加成功'); + } + setEditModalVisible(false); + } catch (error) { + console.error('表单验证失败:', error); + } + }; + + const selectedRecord = deployRecords.find( + (record) => record.id === selectedRecordId, + ); + const buildLogs = getBuildLogs(selectedRecordId); + + // 简单的状态标签渲染函数(仅用于构建日志区域) + const renderStatusTag = (status: DeployRecord['status']) => { + const statusMap: Record< + DeployRecord['status'], + { color: string; text: string } + > = { + success: { color: 'green', text: '成功' }, + running: { color: 'blue', text: '运行中' }, + failed: { color: 'red', text: '失败' }, + pending: { color: 'orange', text: '等待中' }, + }; + const config = statusMap[status]; + return {config.text}; + }; + + // 渲染部署记录项 + const renderDeployRecordItem = (item: DeployRecord, index: number) => { + const isSelected = item.id === selectedRecordId; + return ( + + ); + }; + + return ( +
+
+
+ + {detail?.name} + + 自动化地部署项目 +
+ +
+
+ + +
+ {/* 左侧部署记录列表 */} +
+
+ + 共 {deployRecords.length} 条部署记录 + + +
+
+ +
+
+ + {/* 右侧构建日志 */} +
+
+
+
+ + 构建日志 #{selectedRecordId} + + {selectedRecord && ( + + {selectedRecord.branch} · {selectedRecord.env} ·{' '} + {selectedRecord.createdAt} + + )} +
+ {selectedRecord && ( +
+ {renderStatusTag(selectedRecord.status)} +
+ )} +
+
+
+
+ {buildLogs.map((log: string, index: number) => ( +
+ {log} +
+ ))} +
+
+
+
+
+ +
+ {/* 左侧流水线列表 */} +
+
+ + 共 {pipelines.length} 条流水线 + + +
+
+
+ {pipelines.map((pipeline) => { + const isSelected = pipeline.id === selectedPipelineId; + return ( + setSelectedPipelineId(pipeline.id)} + > +
+
+
+ + {pipeline.name} + + { + // 阻止事件冒泡 + e?.stopPropagation?.(); + handleTogglePipeline(pipeline.id, enabled); + }} + onClick={(e) => e.stopPropagation()} + /> + {!pipeline.enabled && ( + + 已禁用 + + )} +
+ + handleEditPipeline(pipeline)} + > + + 编辑流水线 + + handleCopyPipeline(pipeline)} + > + + 复制流水线 + + + handleDeletePipeline(pipeline.id) + } + > + + 删除流水线 + + + } + position="bottom" + > +
+
+
{pipeline.description}
+
+ 共 {pipeline.steps.length} 个步骤 + {pipeline.updatedAt} +
+
+
+
+ ); + })} + + {pipelines.length === 0 && ( +
+ + 暂无流水线,点击上方"新建流水线"按钮开始创建 + +
+ )} +
+
+
+ + {/* 右侧流水线步骤详情 */} +
+ {selectedPipelineId && pipelines.find(p => p.id === selectedPipelineId) ? ( + (() => { + const selectedPipeline = pipelines.find(p => p.id === selectedPipelineId)!; + return ( + <> +
+
+
+ + {selectedPipeline.name} - 流水线步骤 + + + {selectedPipeline.description} · 共 {selectedPipeline.steps.length} 个步骤 + +
+ +
+
+
+ + step.id)} + strategy={verticalListSortingStrategy} + > +
+ {selectedPipeline.steps.map((step, index) => ( + + ))} + + {selectedPipeline.steps.length === 0 && ( +
+ + 暂无步骤,点击上方"添加步骤"按钮开始配置 + +
+ )} +
+
+
+
+ + ); + })() + ) : ( +
+ + 请选择左侧的流水线查看详细步骤 + +
+ )} +
+
+ + {/* 新建/编辑流水线模态框 */} + setPipelineModalVisible(false)} + style={{ width: 500 }} + > +
+ + + + + + +
+
+ + {/* 编辑步骤模态框 */} + setEditModalVisible(false)} + style={{ width: 600 }} + > +
+ + + + + + +
+ + 可用环境变量: +
• $PROJECT_NAME - 项目名称 +
• $BUILD_NUMBER - 构建编号 +
• $REGISTRY - 镜像仓库地址 +
+
+
+
+
+
+
+
+ ); +} + +export default ProjectDetailPage; diff --git a/apps/web/src/pages/project/detail/service.ts b/apps/web/src/pages/project/detail/service.ts new file mode 100644 index 0000000..17b645e --- /dev/null +++ b/apps/web/src/pages/project/detail/service.ts @@ -0,0 +1,13 @@ +import { net, type APIResponse } from '@shared'; +import type { Project } from '../types'; + +class DetailService { + async getProject(id: string) { + const { code, data } = await net.request>({ + url: `/api/projects/${id}`, + }); + return data; + } +} + +export const detailService = new DetailService(); diff --git a/apps/web/src/pages/project/components/CreateProjectModal.tsx b/apps/web/src/pages/project/list/components/CreateProjectModal.tsx similarity index 100% rename from apps/web/src/pages/project/components/CreateProjectModal.tsx rename to apps/web/src/pages/project/list/components/CreateProjectModal.tsx diff --git a/apps/web/src/pages/project/components/EditProjectModal.tsx b/apps/web/src/pages/project/list/components/EditProjectModal.tsx similarity index 100% rename from apps/web/src/pages/project/components/EditProjectModal.tsx rename to apps/web/src/pages/project/list/components/EditProjectModal.tsx diff --git a/apps/web/src/pages/project/components/ProjectCard.tsx b/apps/web/src/pages/project/list/components/ProjectCard.tsx similarity index 78% rename from apps/web/src/pages/project/components/ProjectCard.tsx rename to apps/web/src/pages/project/list/components/ProjectCard.tsx index 1f7c650..bb77be1 100644 --- a/apps/web/src/pages/project/components/ProjectCard.tsx +++ b/apps/web/src/pages/project/list/components/ProjectCard.tsx @@ -1,7 +1,27 @@ -import { Card, Tag, Avatar, Space, Typography, Button, Tooltip, Dropdown, Menu, Modal } from '@arco-design/web-react'; -import { IconBranch, IconCalendar, IconEye, IconCloud, IconEdit, IconMore, IconDelete } from '@arco-design/web-react/icon'; -import type { Project } from '../types'; +import { + Card, + Tag, + Avatar, + Space, + Typography, + Button, + Tooltip, + Dropdown, + Menu, + Modal, +} from '@arco-design/web-react'; +import { + IconBranch, + IconCalendar, + IconCloud, + 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'; const { Text, Paragraph } = Typography; @@ -12,6 +32,7 @@ interface ProjectCardProps { } function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) { + const navigate = useNavigate(); // 处理删除操作 const handleDelete = () => { Modal.confirm({ @@ -30,7 +51,7 @@ function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) { // 获取环境信息 const environments = [ { name: 'staging', color: 'orange', icon: '🚧' }, - { name: 'production', color: 'green', icon: '🚀' } + { name: 'production', color: 'green', icon: '🚀' }, ]; // 渲染环境标签 @@ -56,11 +77,16 @@ function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) { ); }; + const onProjectClick = useCallback(() => { + navigate(`/project/${project.id}`); + }, [navigate, project.id]); + return ( {/* 项目头部 */}
@@ -90,10 +116,7 @@ function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) { - onEdit?.(project)} - > + onEdit?.(project)}> 编辑 @@ -120,9 +143,7 @@ function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) {
{/* 项目描述 */} - + {project.description || '暂无描述'} @@ -152,25 +173,6 @@ function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) { - - {/* 操作按钮 */} -
- - -
); } diff --git a/apps/web/src/pages/project/index.tsx b/apps/web/src/pages/project/list/index.tsx similarity index 96% rename from apps/web/src/pages/project/index.tsx rename to apps/web/src/pages/project/list/index.tsx index 5507d11..92e40d2 100644 --- a/apps/web/src/pages/project/index.tsx +++ b/apps/web/src/pages/project/list/index.tsx @@ -1,8 +1,8 @@ import { Grid, Typography, Button, Message } 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 type { Project } from '../types'; +import { useAsyncEffect } from '../../../hooks/useAsyncEffect'; import { projectService } from './service'; import ProjectCard from './components/ProjectCard'; import EditProjectModal from './components/EditProjectModal'; diff --git a/apps/web/src/pages/project/service.ts b/apps/web/src/pages/project/list/service.ts similarity index 70% rename from apps/web/src/pages/project/service.ts rename to apps/web/src/pages/project/list/service.ts index 106ac84..5ce4d2c 100644 --- a/apps/web/src/pages/project/service.ts +++ b/apps/web/src/pages/project/list/service.ts @@ -1,5 +1,60 @@ -import { net, type APIResponse } from "@shared"; -import type { Project } from "./types"; +import { net, type APIResponse } from '@shared'; +import type { Project } from '../types'; + + +class ProjectService { + async list(params?: ProjectQueryParams) { + const { data } = await net.request>({ + method: 'GET', + url: '/api/projects', + params, + }); + return data; + } + + async show(id: string) { + const { data } = await net.request>({ + method: 'GET', + url: `/api/projects/${id}`, + }); + return data; + } + + async create(project: { + name: string; + description?: string; + repository: string; + }) { + const { data } = await net.request>({ + method: 'POST', + url: '/api/projects', + data: project, + }); + return data; + } + + async update( + id: string, + project: Partial<{ name: string; description: string; repository: string }>, + ) { + const { data } = await net.request>({ + method: 'PUT', + url: `/api/projects/${id}`, + data: project, + }); + return data; + } + + async delete(id: string) { + await net.request({ + method: 'DELETE', + url: `/api/projects/${id}`, + }); + // DELETE 成功返回 204,无内容 + } +} + +export const projectService = new ProjectService(); interface ProjectListResponse { data: Project[]; @@ -16,55 +71,3 @@ interface ProjectQueryParams { limit?: number; name?: string; } - -class ProjectService { - // GET /api/projects - 获取项目列表 - async list(params?: ProjectQueryParams) { - const { data } = await net.request>({ - method: 'GET', - url: '/api/projects', - params, - }); - return data; - } - - // GET /api/projects/:id - 获取单个项目 - async show(id: string) { - const { data } = await net.request>({ - method: 'GET', - url: `/api/projects/${id}`, - }); - return data; - } - - // POST /api/projects - 创建项目 - async create(project: { name: string; description?: string; repository: string }) { - const { data } = await net.request>({ - method: 'POST', - url: '/api/projects', - data: project, - }); - return data; - } - - // PUT /api/projects/:id - 更新项目 - async update(id: string, project: Partial<{ name: string; description: string; repository: string }>) { - const { data } = await net.request>({ - method: 'PUT', - url: `/api/projects/${id}`, - data: project, - }); - return data; - } - - // DELETE /api/projects/:id - 删除项目 - async delete(id: string) { - await net.request({ - method: 'DELETE', - url: `/api/projects/${id}`, - }); - // DELETE 成功返回 204,无内容 - } -} - -export const projectService = new ProjectService(); diff --git a/package.json b/package.json index fd01cb4..30d73b0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "", "scripts": { - "dev": "pnpm run dev" + "dev": "pnpm --parallel -r run dev" }, "devDependencies": { "@biomejs/biome": "2.0.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad1e61a..12e3bbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,22 +77,31 @@ importers: dependencies: '@arco-design/web-react': specifier: ^2.66.4 - version: 2.66.5(@types/react@19.1.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.66.5(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) axios: specifier: ^1.11.0 version: 1.11.0 react: - specifier: ^18.3.1 + specifier: ^18.2.0 version: 18.3.1 react-dom: - specifier: ^18.3.1 + specifier: ^18.2.0 version: 18.3.1(react@18.3.1) react-router: specifier: ^7.8.0 version: 7.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zustand: specifier: ^5.0.8 - version: 5.0.8(@types/react@19.1.12)(react@18.3.1) + version: 5.0.8(@types/react@18.3.24)(react@18.3.1) devDependencies: '@arco-plugins/unplugin-react': specifier: 2.0.0-beta.5 @@ -113,11 +122,11 @@ importers: specifier: ^4.1.11 version: 4.1.12 '@types/react': - specifier: ^19.1.9 - version: 19.1.12 + specifier: ^18.3.24 + version: 18.3.24 '@types/react-dom': - specifier: ^19.1.7 - version: 19.1.9(@types/react@19.1.12) + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.24) tailwindcss: specifier: ^4.1.11 version: 4.1.12 @@ -273,6 +282,28 @@ packages: cpu: [x64] os: [win32] + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.4.5': resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} @@ -846,19 +877,22 @@ packages: '@types/node@24.3.0': resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@19.1.9': - resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: - '@types/react': ^19.0.0 + '@types/react': ^18.0.0 - '@types/react@19.1.12': - resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} + '@types/react@18.3.24': + resolution: {integrity: sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==} '@types/send@0.17.5': resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} @@ -1949,7 +1983,7 @@ snapshots: dependencies: color: 3.2.1 - '@arco-design/web-react@2.66.5(@types/react@19.1.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@arco-design/web-react@2.66.5(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@arco-design/color': 0.4.0 '@babel/runtime': 7.28.3 @@ -1961,7 +1995,7 @@ snapshots: number-precision: 1.6.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-focus-lock: 2.13.6(@types/react@19.1.12)(react@18.3.1) + react-focus-lock: 2.13.6(@types/react@18.3.24)(react@18.3.1) react-is: 18.3.1 react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) resize-observer-polyfill: 1.5.1 @@ -2112,6 +2146,31 @@ snapshots: '@biomejs/cli-win32-x64@2.0.6': optional: true + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@emnapi/core@1.4.5': dependencies: '@emnapi/wasi-threads': 1.0.4 @@ -2650,16 +2709,19 @@ snapshots: dependencies: undici-types: 7.10.0 + '@types/prop-types@15.7.15': {} + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} - '@types/react-dom@19.1.9(@types/react@19.1.12)': + '@types/react-dom@18.3.7(@types/react@18.3.24)': dependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.24 - '@types/react@19.1.12': + '@types/react@18.3.24': dependencies: + '@types/prop-types': 15.7.15 csstype: 3.1.3 '@types/send@0.17.5': @@ -3462,17 +3524,17 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-focus-lock@2.13.6(@types/react@19.1.12)(react@18.3.1): + react-focus-lock@2.13.6(@types/react@18.3.24)(react@18.3.1): dependencies: '@babel/runtime': 7.28.3 focus-lock: 1.3.6 prop-types: 15.8.1 react: 18.3.1 react-clientside-effect: 1.2.8(react@18.3.1) - use-callback-ref: 1.3.3(@types/react@19.1.12)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@19.1.12)(react@18.3.1) + use-callback-ref: 1.3.3(@types/react@18.3.24)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.24)(react@18.3.1) optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.24 react-is@16.13.1: {} @@ -3659,20 +3721,20 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-callback-ref@1.3.3(@types/react@19.1.12)(react@18.3.1): + use-callback-ref@1.3.3(@types/react@18.3.24)(react@18.3.1): dependencies: react: 18.3.1 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.24 - use-sidecar@1.1.3(@types/react@19.1.12)(react@18.3.1): + use-sidecar@1.1.3(@types/react@18.3.24)(react@18.3.1): dependencies: detect-node-es: 1.1.0 react: 18.3.1 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.24 vary@1.1.2: {} @@ -3686,7 +3748,7 @@ snapshots: zod@4.1.5: {} - zustand@5.0.8(@types/react@19.1.12)(react@18.3.1): + zustand@5.0.8(@types/react@18.3.24)(react@18.3.1): optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.24 react: 18.3.1