Files
foka-ci/apps/web/src/pages/project/detail/index.tsx

922 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Button,
Card,
Dropdown,
Empty,
Form,
Input,
List,
Menu,
Message,
Modal,
Switch,
Tabs,
Tag,
Typography,
} from '@arco-design/web-react';
import {
IconCopy,
IconDelete,
IconEdit,
IconMore,
IconPlayArrow,
IconPlus,
} from '@arco-design/web-react/icon';
import type { DragEndEvent } from '@dnd-kit/core';
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useState } from 'react';
import { useParams } from 'react-router';
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
import type { Deployment, Pipeline, Project, Step } from '../types';
import DeployModal from './components/DeployModal';
import DeployRecordItem from './components/DeployRecordItem';
import PipelineStepItem from './components/PipelineStepItem';
import { detailService } from './service';
interface StepWithEnabled extends Step {
enabled: boolean;
}
interface PipelineWithEnabled extends Pipeline {
steps?: StepWithEnabled[];
enabled: boolean;
}
function ProjectDetailPage() {
const [detail, setDetail] = useState<Project | null>();
// 拖拽传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [selectedRecordId, setSelectedRecordId] = useState<number>(1);
const [pipelines, setPipelines] = useState<PipelineWithEnabled[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedPipelineId, setSelectedPipelineId] = useState<number>(0);
const [editingStep, setEditingStep] = useState<StepWithEnabled | null>(null);
const [editingPipelineId, setEditingPipelineId] = useState<number | null>(
null,
);
const [pipelineModalVisible, setPipelineModalVisible] = useState(false);
const [editingPipeline, setEditingPipeline] = useState<PipelineWithEnabled | null>(null);
const [form] = Form.useForm();
const [pipelineForm] = Form.useForm();
const [deployRecords, setDeployRecords] = useState<Deployment[]>([]);
const [deployModalVisible, setDeployModalVisible] = useState(false);
const { id } = useParams();
useAsyncEffect(async () => {
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('获取流水线数据失败');
}
// 获取部署记录
try {
const records = await detailService.getDeployments(Number(id));
setDeployRecords(records);
if (records.length > 0) {
setSelectedRecordId(records[0].id);
}
} catch (error) {
console.error('获取部署记录失败:', error);
Message.error('获取部署记录失败');
}
}
}, []);
// 获取构建日志
const getBuildLogs = (recordId: number): string[] => {
const record = deployRecords.find((r) => r.id === recordId);
if (!record || !record.buildLog) {
return ['暂无日志记录'];
}
return record.buildLog.split('\n');
};
// 触发部署
const handleDeploy = () => {
setDeployModalVisible(true);
};
// 添加新流水线
const handleAddPipeline = () => {
setEditingPipeline(null);
pipelineForm.resetFields();
setPipelineModalVisible(true);
};
// 编辑流水线
const handleEditPipeline = (pipeline: PipelineWithEnabled) => {
setEditingPipeline(pipeline);
pipelineForm.setFieldsValue({
name: pipeline.name,
description: pipeline.description,
});
setPipelineModalVisible(true);
};
// 删除流水线
const handleDeletePipeline = async (pipelineId: number) => {
Modal.confirm({
title: '确认删除',
content:
'确定要删除这个流水线吗?此操作不可撤销,将同时删除该流水线下的所有步骤。',
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 = async (pipeline: PipelineWithEnabled) => {
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 = async (pipelineId: number, enabled: boolean) => {
// 在数据库中更新流水线状态这里简化处理实际可能需要添加enabled字段到数据库
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === pipelineId ? { ...pipeline, enabled } : pipeline,
),
);
};
// 保存流水线
const handleSavePipeline = async () => {
try {
const values = await pipelineForm.validate();
if (editingPipeline) {
// 更新现有流水线
const updatedPipeline = await detailService.updatePipeline(
editingPipeline.id,
{
name: values.name,
description: values.description,
},
);
// 更新本地状态
setPipelines((prev) =>
prev.map((pipeline) =>
pipeline.id === editingPipeline.id
? {
...updatedPipeline,
description: updatedPipeline.description || '',
enabled: updatedPipeline.valid === 1,
steps: pipeline.steps || [], // 保持步骤不变
}
: pipeline,
),
);
Message.success('流水线更新成功');
} else {
// 创建新流水线
const newPipeline = await detailService.createPipeline({
name: values.name,
description: values.description || '',
projectId: Number(id),
});
// 更新本地状态
const pipelineWithDefaults = {
...newPipeline,
description: newPipeline.description || '',
enabled: newPipeline.valid === 1,
steps: [],
};
setPipelines((prev) => [...prev, pipelineWithDefaults]);
// 自动选中新创建的流水线
setSelectedPipelineId(newPipeline.id);
Message.success('流水线创建成功');
}
setPipelineModalVisible(false);
} catch (error) {
console.error('保存流水线失败:', error);
Message.error('保存流水线失败');
}
};
// 添加新步骤
const handleAddStep = (pipelineId: number) => {
setEditingStep(null);
setEditingPipelineId(pipelineId);
form.resetFields();
setEditModalVisible(true);
};
// 编辑步骤
const handleEditStep = (pipelineId: number, step: StepWithEnabled) => {
setEditingStep(step);
setEditingPipelineId(pipelineId);
form.setFieldsValue({
name: step.name,
script: step.script,
});
setEditModalVisible(true);
};
// 删除步骤
const handleDeleteStep = async (pipelineId: number, stepId: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个流水线步骤吗?此操作不可撤销。',
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 = 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,
) || [],
updatedAt: new Date().toISOString(),
}
: pipeline,
),
);
};
// 拖拽结束处理
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
if (selectedPipelineId) {
// 更新步骤顺序到数据库简化处理实际应该更新所有步骤的order字段
setPipelines((prev) =>
prev.map((pipeline) => {
if (pipeline.id === selectedPipelineId) {
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: pipeline.steps
? arrayMove(pipeline.steps, oldIndex, newIndex)
: [],
updatedAt: new Date().toISOString(),
};
}
return pipeline;
}),
);
Message.success('步骤顺序调整成功');
}
};
// 保存步骤
const handleSaveStep = async () => {
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
? { ...updatedStep, enabled: step.enabled }
: step,
) || [],
updatedAt: new Date().toISOString(),
}
: pipeline,
),
);
Message.success('步骤更新成功');
} else if (editingPipelineId) {
// 创建新步骤
const newStep = await detailService.createStep({
name: values.name,
script: values.script,
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, enabled: true },
],
updatedAt: new Date().toISOString(),
}
: pipeline,
),
);
Message.success('步骤添加成功');
}
setEditModalVisible(false);
} catch (error) {
console.error('保存步骤失败:', error);
Message.error('保存步骤失败');
}
};
const selectedRecord = deployRecords.find(
(record) => record.id === selectedRecordId,
);
const buildLogs = getBuildLogs(selectedRecordId);
// 简单的状态标签渲染函数(仅用于构建日志区域)
const renderStatusTag = (status: Deployment['status']) => {
const statusMap: Record<string, { 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 <Tag color={config.color}>{config.text}</Tag>;
};
// 渲染部署记录项
const renderDeployRecordItem = (item: Deployment, _index: number) => {
const isSelected = item.id === selectedRecordId;
return (
<DeployRecordItem
key={item.id}
item={item}
isSelected={isSelected}
onSelect={setSelectedRecordId}
/>
);
};
return (
<div className="p-6 flex flex-col h-full">
<div className="mb-6 flex items-center justify-between">
<div>
<Typography.Title heading={2} className="!m-0 !text-gray-900">
{detail?.name}
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button type="primary" icon={<IconPlayArrow />} onClick={handleDeploy}>
</Button>
</div>
<div className="bg-white p-6 rounded-lg shadow-md flex-1 flex flex-col overflow-hidden">
<Tabs
type="line"
size="large"
className="h-full flex flex-col [&>.arco-tabs-content]:flex-1 [&>.arco-tabs-content]:overflow-hidden [&>.arco-tabs-content_.arco-tabs-content-inner]:h-full [&>.arco-tabs-content_.arco-tabs-pane]:h-full"
>
<Tabs.TabPane title="部署记录" key="deployRecords">
<div className="grid grid-cols-5 gap-6 h-full">
{/* 左侧部署记录列表 */}
<div className="col-span-2 space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
<Typography.Text type="secondary">
{deployRecords.length}
</Typography.Text>
<Button size="small" type="outline">
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{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>
{/* 右侧构建日志 */}
<div className="col-span-3 bg-white rounded-lg border h-full overflow-hidden flex flex-col">
<div className="p-4 border-b bg-gray-50 shrink-0">
<div className="flex items-center justify-between">
<div>
<Typography.Title heading={5} className="!m-0">
#{selectedRecordId}
</Typography.Title>
{selectedRecord && (
<Typography.Text type="secondary" className="text-sm">
{selectedRecord.branch} · {selectedRecord.env} ·{' '}
{selectedRecord.createdAt}
</Typography.Text>
)}
</div>
{selectedRecord && (
<div className="flex items-center gap-2">
{renderStatusTag(selectedRecord.status)}
</div>
)}
</div>
</div>
<div className="p-4 flex-1 overflow-hidden flex flex-col">
<div className="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm flex-1 overflow-y-auto">
{buildLogs.map((log: string, index: number) => (
<div
key={`${selectedRecordId}-${log.slice(0, 30)}-${index}`}
className="mb-1 leading-relaxed"
>
{log}
</div>
))}
</div>
</div>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane title="流水线" key="pipeline">
<div className="grid grid-cols-5 gap-6 h-full">
{/* 左侧流水线列表 */}
<div className="col-span-2 space-y-4">
<div className="flex items-center justify-between">
<Typography.Text type="secondary">
{pipelines.length} 线
</Typography.Text>
<Button
type="primary"
icon={<IconPlus />}
size="small"
onClick={handleAddPipeline}
>
线
</Button>
</div>
<div className="h-full overflow-y-auto">
<div className="space-y-3">
{pipelines.map((pipeline) => {
const isSelected = pipeline.id === selectedPipelineId;
return (
<Card
key={pipeline.id}
className={`cursor-pointer transition-all duration-200 ${
isSelected
? 'bg-blue-50 border-l-4 border-blue-500'
: 'hover:bg-gray-50 border-gray-200'
}`}
onClick={() => setSelectedPipelineId(pipeline.id)}
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Typography.Title
heading={6}
className={`!m-0 ${
isSelected
? 'text-blue-600'
: 'text-gray-900'
}`}
>
{pipeline.name}
</Typography.Title>
<Switch
size="small"
checked={pipeline.enabled}
onChange={(enabled, e) => {
// 阻止事件冒泡
e?.stopPropagation?.();
handleTogglePipeline(pipeline.id, enabled);
}}
onClick={(e) => e.stopPropagation()}
/>
{!pipeline.enabled && (
<Tag color="gray" size="small">
</Tag>
)}
</div>
<Dropdown
droplist={
<Menu>
<Menu.Item
key="edit"
onClick={() =>
handleEditPipeline(pipeline)
}
>
<IconEdit className="mr-2" />
线
</Menu.Item>
<Menu.Item
key="copy"
onClick={() =>
handleCopyPipeline(pipeline)
}
>
<IconCopy className="mr-2" />
线
</Menu.Item>
<Menu.Item
key="delete"
onClick={() =>
handleDeletePipeline(pipeline.id)
}
>
<IconDelete className="mr-2" />
线
</Menu.Item>
</Menu>
}
position="bottom"
>
<Button
type="text"
size="small"
icon={<IconMore />}
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded-md p-1 transition-all duration-200"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
</div>
<div className="text-sm text-gray-500">
<div>{pipeline.description}</div>
<div className="flex items-center justify-between mt-2">
<span>
{pipeline.steps?.length || 0}
</span>
<span>
{new Date(
pipeline.updatedAt,
).toLocaleString()}
</span>
</div>
</div>
</div>
</Card>
);
})}
{pipelines.length === 0 && (
<div className="text-center py-12">
<Empty description="暂无流水线" />
<Typography.Text type="secondary">
"新建流水线"
</Typography.Text>
</div>
)}
</div>
</div>
</div>
{/* 右侧流水线步骤详情 */}
<div className="col-span-3 bg-white rounded-lg border h-full overflow-hidden">
{selectedPipelineId &&
pipelines.find((p) => p.id === selectedPipelineId) ? (
(() => {
const selectedPipeline = pipelines.find(
(p) => p.id === selectedPipelineId,
);
if (!selectedPipeline) return null;
return (
<>
<div className="p-4 border-b bg-gray-50">
<div className="flex items-center justify-between">
<div>
<Typography.Title heading={5} className="!m-0">
{selectedPipeline.name} - 线
</Typography.Title>
<Typography.Text
type="secondary"
className="text-sm"
>
{selectedPipeline.description} · {' '}
{selectedPipeline.steps?.length || 0}
</Typography.Text>
</div>
<Button
type="primary"
icon={<IconPlus />}
size="small"
onClick={() => handleAddStep(selectedPipelineId)}
>
</Button>
</div>
</div>
<div className="p-4 h-full overflow-y-auto">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={
selectedPipeline.steps?.map(
(step) => step.id,
) || []
}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{selectedPipeline.steps?.map((step, index) => (
<PipelineStepItem
key={step.id}
step={step}
index={index}
pipelineId={selectedPipelineId}
onToggle={handleToggleStep}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
/>
))}
{selectedPipeline.steps?.length === 0 && (
<div className="text-center py-12">
<Empty description="暂无步骤" />
<Typography.Text type="secondary">
"添加步骤"
</Typography.Text>
</div>
)}
</div>
</SortableContext>
</DndContext>
</div>
</>
);
})()
) : (
<div className="flex items-center justify-center h-full">
<Empty description="请选择流水线" />
</div>
)}
</div>
</div>
{/* 新建/编辑流水线模态框 */}
<Modal
title={editingPipeline ? '编辑流水线' : '新建流水线'}
visible={pipelineModalVisible}
onOk={handleSavePipeline}
onCancel={() => setPipelineModalVisible(false)}
style={{ width: 500 }}
>
<Form form={pipelineForm} layout="vertical">
<Form.Item
field="name"
label="流水线名称"
rules={[{ required: true, message: '请输入流水线名称' }]}
>
<Input placeholder="例如前端部署流水线、Docker部署流水线..." />
</Form.Item>
<Form.Item
field="description"
label="流水线描述"
rules={[{ required: true, message: '请输入流水线描述' }]}
>
<Input.TextArea
placeholder="描述这个流水线的用途和特点..."
rows={3}
/>
</Form.Item>
</Form>
</Modal>
{/* 编辑步骤模态框 */}
<Modal
title={editingStep ? '编辑流水线步骤' : '添加流水线步骤'}
visible={editModalVisible}
onOk={handleSaveStep}
onCancel={() => setEditModalVisible(false)}
style={{ width: 600 }}
>
<Form form={form} layout="vertical">
<Form.Item
field="name"
label="步骤名称"
rules={[{ required: true, message: '请输入步骤名称' }]}
>
<Input placeholder="例如:安装依赖、运行测试、构建项目..." />
</Form.Item>
<Form.Item
field="script"
label="Shell 脚本"
rules={[{ required: true, message: '请输入脚本内容' }]}
>
<Input.TextArea
placeholder="例如npm install&#10;npm test&#10;npm run build"
rows={8}
style={{ fontFamily: 'Monaco, Consolas, monospace' }}
/>
</Form.Item>
<div className="bg-blue-50 p-3 rounded text-sm">
<Typography.Text type="secondary">
<strong></strong>
<br /> $PROJECT_NAME -
<br /> $BUILD_NUMBER -
<br /> $REGISTRY -
</Typography.Text>
</div>
</Form>
</Modal>
</Tabs.TabPane>
</Tabs>
</div>
<DeployModal
visible={deployModalVisible}
onCancel={() => setDeployModalVisible(false)}
onOk={() => {
setDeployModalVisible(false);
// 刷新部署记录
if (id) {
detailService.getDeployments(Number(id)).then((records) => {
setDeployRecords(records);
if (records.length > 0) {
setSelectedRecordId(records[0].id);
}
});
}
}}
pipelines={pipelines}
projectId={Number(id)}
/>
</div>
);
}
export default ProjectDetailPage;