feat: Introduce DTOs for API validation and new deployment features, including a Git controller and UI components.

This commit is contained in:
2025-11-23 12:03:11 +08:00
parent 02b7c3edb2
commit 378070179f
24 changed files with 809 additions and 302 deletions

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const loginSchema = z.object({
code: z.string().min(1, { message: 'Code不能为空' }),
});
export type LoginInput = z.infer<typeof loginSchema>;

View File

@@ -3,6 +3,7 @@ import { Controller, Get, Post } from '../../decorators/route.ts';
import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts';
import { gitea } from '../../libs/gitea.ts';
import { loginSchema } from './dto.ts';
@Controller('/auth')
export class AuthController {
@@ -20,7 +21,7 @@ export class AuthController {
if (ctx.session.user) {
return ctx.session.user;
}
const { code } = ctx.request.body as LoginRequestBody;
const { code } = loginSchema.parse(ctx.request.body);
const { access_token, refresh_token, expires_in } =
await gitea.getToken(code);
const giteaAuth = {
@@ -81,7 +82,3 @@ export class AuthController {
return ctx.session?.user;
}
}
interface LoginRequestBody {
code: string;
}

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const listDeploymentsQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1),
pageSize: z.coerce.number().int().min(1).max(100).optional().default(10),
projectId: z.coerce.number().int().positive().optional(),
});
export const createDeploymentSchema = z.object({
projectId: z.number().int().positive({ message: '项目ID必须是正整数' }),
pipelineId: z.number().int().positive({ message: '流水线ID必须是正整数' }),
branch: z.string().min(1, { message: '分支不能为空' }),
commitHash: z.string().min(1, { message: '提交哈希不能为空' }),
commitMessage: z.string().min(1, { message: '提交信息不能为空' }),
env: z.string().optional(),
});
export type ListDeploymentsQuery = z.infer<typeof listDeploymentsQuerySchema>;
export type CreateDeploymentInput = z.infer<typeof createDeploymentSchema>;

View File

@@ -1,45 +1,61 @@
import { Controller, Get, Post } from '../../decorators/route.ts';
import type { Prisma } from '../../generated/prisma/index.js';
import type { Prisma } from '../../generated/client.ts';
import { prisma } from '../../libs/prisma.ts';
import type { Context } from 'koa';
import { listDeploymentsQuerySchema, createDeploymentSchema } from './dto.ts';
@Controller('/deployments')
export class DeploymentController {
@Get('')
async list(ctx: Context) {
const { page = 1, pageSize = 10 } = ctx.query;
const result = await prisma.deployment.findMany({
where: {
const { page, pageSize, projectId } = listDeploymentsQuerySchema.parse(ctx.query);
const where: Prisma.DeploymentWhereInput = {
valid: 1,
},
take: Number(pageSize),
skip: (Number(page) - 1) * Number(pageSize),
};
if (projectId) {
where.projectId = projectId;
}
const result = await prisma.deployment.findMany({
where,
take: pageSize,
skip: (page - 1) * pageSize,
orderBy: {
createdAt: 'desc',
},
});
const total = await prisma.deployment.count();
const total = await prisma.deployment.count({ where });
return {
data: result,
page: Number(page),
pageSize: Number(pageSize),
total: total,
page,
pageSize,
total,
};
}
@Post('')
async create(ctx: Context) {
const body = ctx.request.body as Prisma.DeploymentCreateInput;
const body = createDeploymentSchema.parse(ctx.request.body);
prisma.deployment.create({
const result = await prisma.deployment.create({
data: {
branch: body.branch,
commitHash: body.commitHash,
commitMessage: body.commitMessage,
status: 'pending',
Project: {
connect: { id: body.projectId },
},
pipelineId: body.pipelineId,
env: body.env || 'dev',
buildLog: '',
createdBy: 'system', // TODO: get from user
updatedBy: 'system',
valid: 1,
},
});
return result;
}
}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const getCommitsQuerySchema = z.object({
projectId: z.coerce.number().int().positive({ message: 'Project ID is required' }),
branch: z.string().optional(),
});
export const getBranchesQuerySchema = z.object({
projectId: z.coerce.number().int().positive({ message: 'Project ID is required' }),
});
export type GetCommitsQuery = z.infer<typeof getCommitsQuerySchema>;
export type GetBranchesQuery = z.infer<typeof getBranchesQuerySchema>;

View File

@@ -0,0 +1,113 @@
import type { Context } from 'koa';
import { Controller, Get } from '../../decorators/route.ts';
import { prisma } from '../../libs/prisma.ts';
import { gitea } from '../../libs/gitea.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import { getCommitsQuerySchema, getBranchesQuerySchema } from './dto.ts';
@Controller('/git')
export class GitController {
@Get('/commits')
async getCommits(ctx: Context) {
const { projectId, branch } = getCommitsQuerySchema.parse(ctx.query);
const project = await prisma.project.findFirst({
where: {
id: projectId,
valid: 1,
},
});
if (!project) {
throw new BusinessError('Project not found', 1002, 404);
}
// Parse repository URL to get owner and repo
// Supports:
// https://gitea.com/owner/repo.git
// http://gitea.com/owner/repo
const { owner, repo } = this.parseRepoUrl(project.repository);
// Get access token from session
const accessToken = ctx.session?.gitea?.access_token;
console.log('Access token present:', !!accessToken);
if (!accessToken) {
throw new BusinessError('Gitea access token not found. Please login again.', 1004, 401);
}
try {
const commits = await gitea.getCommits(owner, repo, accessToken, branch);
return commits;
} catch (error) {
console.error('Failed to fetch commits:', error);
throw new BusinessError('Failed to fetch commits from Gitea', 1005, 500);
}
}
@Get('/branches')
async getBranches(ctx: Context) {
const { projectId } = getBranchesQuerySchema.parse(ctx.query);
const project = await prisma.project.findFirst({
where: {
id: projectId,
valid: 1,
},
});
if (!project) {
throw new BusinessError('Project not found', 1002, 404);
}
const { owner, repo } = this.parseRepoUrl(project.repository);
const accessToken = ctx.session?.gitea?.access_token;
if (!accessToken) {
throw new BusinessError('Gitea access token not found. Please login again.', 1004, 401);
}
try {
const branches = await gitea.getBranches(owner, repo, accessToken);
return branches;
} catch (error) {
console.error('Failed to fetch branches:', error);
throw new BusinessError('Failed to fetch branches from Gitea', 1006, 500);
}
}
private parseRepoUrl(url: string) {
let cleanUrl = url.trim();
if (cleanUrl.endsWith('/')) {
cleanUrl = cleanUrl.slice(0, -1);
}
// Handle SCP-like syntax: git@host:owner/repo.git
if (!cleanUrl.includes('://') && cleanUrl.includes(':')) {
const scpMatch = cleanUrl.match(/:([^\/]+)\/([^\/]+?)(\.git)?$/);
if (scpMatch) {
return { owner: scpMatch[1], repo: scpMatch[2] };
}
}
// Handle HTTP/HTTPS/SSH URLs
try {
const urlObj = new URL(cleanUrl);
const parts = urlObj.pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
const repo = parts.pop()!.replace(/\.git$/, '');
const owner = parts.pop()!;
return { owner, repo };
}
} catch (e) {
// Fallback to simple regex
const match = cleanUrl.match(/([^\/]+)\/([^\/]+?)(\.git)?$/);
if (match) {
return { owner: match[1], repo: match[2] };
}
}
throw new BusinessError('Invalid repository URL format', 1003, 400);
}
}

View File

@@ -5,3 +5,4 @@ export { AuthController } from './auth/index.ts';
export { DeploymentController } from './deployment/index.ts';
export { PipelineController } from './pipeline/index.ts';
export { StepController } from './step/index.ts'
export { GitController } from './git/index.ts';

View File

@@ -8,7 +8,7 @@ import {
updatePipelineSchema,
pipelineIdSchema,
listPipelinesQuerySchema,
} from './schema.ts';
} from './dto.ts';
@Controller('/pipelines')
export class PipelineController {

View File

@@ -8,7 +8,7 @@ import {
updateProjectSchema,
listProjectQuerySchema,
projectIdSchema,
} from './schema.ts';
} from './dto.ts';
@Controller('/projects')
export class ProjectController {

View File

@@ -0,0 +1,103 @@
import { z } from 'zod';
// 定义验证架构
export const createStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' }),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' }),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' }),
pipelineId: z
.number({
message: '流水线ID必须是数字',
})
.int()
.positive({ message: '流水线ID必须是正整数' }),
});
export const updateStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' })
.optional(),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' })
.optional(),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' })
.optional(),
});
export const stepIdSchema = z.object({
id: z.coerce.number().int().positive({ message: '步骤 ID 必须是正整数' }),
});
export const listStepsQuerySchema = z
.object({
pipelineId: z.coerce
.number()
.int()
.positive({ message: '流水线ID必须是正整数' })
.optional(),
page: z.coerce
.number()
.int()
.min(1, { message: '页码必须大于0' })
.optional()
.default(1),
limit: z.coerce
.number()
.int()
.min(1, { message: '每页数量必须大于0' })
.max(100, { message: '每页数量不能超过100' })
.optional()
.default(10),
})
.optional();
// TypeScript 类型
export type CreateStepInput = z.infer<typeof createStepSchema>;
export type UpdateStepInput = z.infer<typeof updateStepSchema>;
export type StepIdParams = z.infer<typeof stepIdSchema>;
export type ListStepsQuery = z.infer<typeof listStepsQuerySchema>;

View File

@@ -3,109 +3,12 @@ import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
import { z } from 'zod';
// 定义验证架构
const createStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' }),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' }),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' }),
pipelineId: z
.number({
message: '流水线ID必须是数字',
})
.int()
.positive({ message: '流水线ID必须是正整数' }),
});
const updateStepSchema = z.object({
name: z
.string({
message: '步骤名称必须是字符串',
})
.min(1, { message: '步骤名称不能为空' })
.max(100, { message: '步骤名称不能超过100个字符' })
.optional(),
description: z
.string({
message: '步骤描述必须是字符串',
})
.max(500, { message: '步骤描述不能超过500个字符' })
.optional(),
order: z
.number({
message: '步骤顺序必须是数字',
})
.int()
.min(0, { message: '步骤顺序必须是非负整数' })
.optional(),
script: z
.string({
message: '脚本命令必须是字符串',
})
.min(1, { message: '脚本命令不能为空' })
.optional(),
});
const stepIdSchema = z.object({
id: z.coerce.number().int().positive({ message: '步骤 ID 必须是正整数' }),
});
const listStepsQuerySchema = z
.object({
pipelineId: z.coerce
.number()
.int()
.positive({ message: '流水线ID必须是正整数' })
.optional(),
page: z.coerce
.number()
.int()
.min(1, { message: '页码必须大于0' })
.optional()
.default(1),
limit: z.coerce
.number()
.int()
.min(1, { message: '每页数量必须大于0' })
.max(100, { message: '每页数量不能超过100' })
.optional()
.default(10),
})
.optional();
// TypeScript 类型
type CreateStepInput = z.infer<typeof createStepSchema>;
type UpdateStepInput = z.infer<typeof updateStepSchema>;
type StepIdParams = z.infer<typeof stepIdSchema>;
type ListStepsQuery = z.infer<typeof listStepsQuerySchema>;
import {
createStepSchema,
updateStepSchema,
stepIdSchema,
listStepsQuerySchema,
} from './dto.ts';
@Controller('/steps')
export class StepController {
@@ -185,7 +88,6 @@ export class StepController {
const step = await prisma.step.create({
data: {
name: validatedData.name,
description: validatedData.description || '',
order: validatedData.order,
script: validatedData.script,
pipelineId: validatedData.pipelineId,

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
export const userIdSchema = z.object({
id: z.coerce.number().int().positive({ message: '用户ID必须是正整数' }),
});
export const createUserSchema = z.object({
name: z.string().min(1, { message: '用户名不能为空' }),
email: z.string().email({ message: '邮箱格式不正确' }),
status: z.enum(['active', 'inactive']).optional().default('active'),
});
export const updateUserSchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
status: z.enum(['active', 'inactive']).optional(),
});
export const searchUserQuerySchema = z.object({
keyword: z.string().optional(),
status: z.enum(['active', 'inactive']).optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type SearchUserQuery = z.infer<typeof searchUserQuerySchema>;

View File

@@ -1,6 +1,12 @@
import type { Context } from 'koa';
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import {
userIdSchema,
createUserSchema,
updateUserSchema,
searchUserQuerySchema,
} from './dto.ts';
/**
* 用户控制器
@@ -22,18 +28,18 @@ export class UserController {
@Get('/detail/:id')
async detail(ctx: Context) {
const { id } = ctx.params;
const { id } = userIdSchema.parse(ctx.params);
// 模拟根据ID查找用户
const user = {
id: Number(id),
id,
name: 'User ' + id,
email: `user${id}@example.com`,
status: 'active',
createdAt: new Date().toISOString()
};
if (Number(id) > 100) {
if (id > 100) {
throw new BusinessError('用户不存在', 2001, 404);
}
@@ -42,14 +48,14 @@ export class UserController {
@Post('')
async create(ctx: Context) {
const body = (ctx.request as any).body;
const body = createUserSchema.parse(ctx.request.body);
// 模拟创建用户
const newUser = {
id: Date.now(),
...body,
createdAt: new Date().toISOString(),
status: 'active'
status: body.status
};
return newUser;
@@ -57,12 +63,12 @@ export class UserController {
@Put('/:id')
async update(ctx: Context) {
const { id } = ctx.params;
const body = (ctx.request as any).body;
const { id } = userIdSchema.parse(ctx.params);
const body = updateUserSchema.parse(ctx.request.body);
// 模拟更新用户
const updatedUser = {
id: Number(id),
id,
...body,
updatedAt: new Date().toISOString()
};
@@ -72,9 +78,9 @@ export class UserController {
@Delete('/:id')
async delete(ctx: Context) {
const { id } = ctx.params;
const { id } = userIdSchema.parse(ctx.params);
if (Number(id) === 1) {
if (id === 1) {
throw new BusinessError('管理员账户不能删除', 2002, 403);
}
@@ -88,7 +94,7 @@ export class UserController {
@Get('/search')
async search(ctx: Context) {
const { keyword, status } = ctx.query;
const { keyword, status } = searchUserQuerySchema.parse(ctx.query);
// 模拟搜索逻辑
let results = [
@@ -98,8 +104,8 @@ export class UserController {
if (keyword) {
results = results.filter(user =>
user.name.toLowerCase().includes(String(keyword).toLowerCase()) ||
user.email.toLowerCase().includes(String(keyword).toLowerCase())
user.name.toLowerCase().includes(keyword.toLowerCase()) ||
user.email.toLowerCase().includes(keyword.toLowerCase())
);
}

View File

@@ -80,6 +80,54 @@ class Gitea {
return result;
}
/**
* 获取仓库分支列表
* @param owner 仓库拥有者
* @param repo 仓库名称
* @param accessToken 访问令牌
*/
async getBranches(owner: string, repo: string, accessToken: string) {
const response = await fetch(
`${this.config.giteaUrl}/api/v1/repos/${owner}/${repo}/branches`,
{
method: 'GET',
headers: this.getHeaders(accessToken),
},
);
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
const result = await response.json();
return result;
}
/**
* 获取仓库提交记录
* @param owner 仓库拥有者
* @param repo 仓库名称
* @param accessToken 访问令牌
* @param sha 分支名称或提交SHA
*/
async getCommits(owner: string, repo: string, accessToken: string, sha?: string) {
const url = new URL(`${this.config.giteaUrl}/api/v1/repos/${owner}/${repo}/commits`);
if (sha) {
url.searchParams.append('sha', sha);
}
const response = await fetch(
url.toString(),
{
method: 'GET',
headers: this.getHeaders(accessToken),
},
);
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
const result = await response.json();
return result;
}
private getHeaders(accessToken?: string) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',

View File

@@ -8,7 +8,8 @@ import {
AuthController,
DeploymentController,
PipelineController,
StepController
StepController,
GitController
} from '../controllers/index.ts';
import { log } from '../libs/logger.ts';
@@ -43,7 +44,8 @@ export class Router implements Middleware {
AuthController,
DeploymentController,
PipelineController,
StepController
StepController,
GitController
]);
// 输出注册的路由信息

Binary file not shown.

View File

@@ -0,0 +1,243 @@
import {
Button,
Form,
Input,
Message,
Modal,
Select,
} from '@arco-design/web-react';
import { IconDelete, IconPlus } from '@arco-design/web-react/icon';
import { useCallback, useEffect, useState } from 'react';
import type { Branch, Commit, Pipeline } from '../../types';
import { detailService } from '../service';
interface DeployModalProps {
visible: boolean;
onCancel: () => void;
onOk: () => void;
pipelines: Pipeline[];
projectId: number;
}
function DeployModal({
visible,
onCancel,
onOk,
pipelines,
projectId,
}: DeployModalProps) {
const [form] = Form.useForm();
const [branches, setBranches] = useState<Branch[]>([]);
const [commits, setCommits] = useState<Commit[]>([]);
const [loading, setLoading] = useState(false);
const [branchLoading, setBranchLoading] = useState(false);
const fetchCommits = useCallback(
async (branch: string) => {
try {
setLoading(true);
const data = await detailService.getCommits(projectId, branch);
setCommits(data);
if (data.length > 0) {
form.setFieldValue('commitHash', data[0].sha);
}
} catch (error) {
console.error('获取提交记录失败:', error);
Message.error('获取提交记录失败');
} finally {
setLoading(false);
}
},
[projectId, form],
);
const fetchBranches = useCallback(async () => {
try {
setBranchLoading(true);
const data = await detailService.getBranches(projectId);
setBranches(data);
// 默认选中 master 或 main
const defaultBranch = data.find(
(b) => b.name === 'master' || b.name === 'main',
);
if (defaultBranch) {
form.setFieldValue('branch', defaultBranch.name);
fetchCommits(defaultBranch.name);
} else if (data.length > 0) {
form.setFieldValue('branch', data[0].name);
fetchCommits(data[0].name);
}
} catch (error) {
console.error('获取分支列表失败:', error);
Message.error('获取分支列表失败');
} finally {
setBranchLoading(false);
}
}, [projectId, form, fetchCommits]);
useEffect(() => {
if (visible && projectId) {
fetchBranches();
}
}, [visible, projectId, fetchBranches]);
const handleBranchChange = (value: string) => {
fetchCommits(value);
form.setFieldValue('commitHash', undefined);
};
const handleSubmit = async () => {
try {
const values = await form.validate();
const selectedCommit = commits.find((c) => c.sha === values.commitHash);
const selectedPipeline = pipelines.find((p) => p.id === values.pipelineId);
if (!selectedCommit || !selectedPipeline) {
return;
}
// 格式化环境变量
const env = values.envVars
?.map((item: { key: string; value: string }) => `${item.key}=${item.value}`)
.join('\n');
await detailService.createDeployment({
projectId,
pipelineId: values.pipelineId,
branch: values.branch,
commitHash: selectedCommit.sha,
commitMessage: selectedCommit.commit.message,
env: env,
});
Message.success('部署任务已创建');
onOk();
} catch (error) {
console.error('创建部署失败:', error);
Message.error('创建部署失败');
}
};
return (
<Modal
title="开始部署"
visible={visible}
onOk={handleSubmit}
onCancel={onCancel}
autoFocus={false}
focusLock={true}
>
<Form form={form} layout="vertical">
<Form.Item
label="选择流水线"
field="pipelineId"
rules={[{ required: true, message: '请选择流水线' }]}
>
<Select placeholder="请选择流水线">
{pipelines.map((pipeline) => (
<Select.Option key={pipeline.id} value={pipeline.id}>
{pipeline.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="选择分支"
field="branch"
rules={[{ required: true, message: '请选择分支' }]}
>
<Select
placeholder="请选择分支"
loading={branchLoading}
onChange={handleBranchChange}
>
{branches.map((branch) => (
<Select.Option key={branch.name} value={branch.name}>
{branch.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="选择提交"
field="commitHash"
rules={[{ required: true, message: '请选择提交记录' }]}
>
<Select
placeholder="请选择提交记录"
loading={loading}
renderFormat={(option) => {
const commit = commits.find((c) => c.sha === option?.value);
return commit ? commit.sha.substring(0, 7) : '';
}}
>
{commits.map((commit) => (
<Select.Option key={commit.sha} value={commit.sha}>
<div className="flex flex-col py-1">
<div className="flex items-center justify-between">
<span className="font-mono font-medium">
{commit.sha.substring(0, 7)}
</span>
<span className="text-gray-500 text-xs">
{new Date(commit.commit.author.date).toLocaleString()}
</span>
</div>
<div className="text-gray-600 text-sm truncate">
{commit.commit.message}
</div>
<div className="text-gray-400 text-xs">
{commit.commit.author.name}
</div>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
<div className="mb-2 font-medium text-gray-700"></div>
<Form.List field="envVars">
{(fields, { add, remove }) => (
<div>
{fields.map((item, index) => (
<div key={item.key} className="flex items-center gap-2 mb-2">
<Form.Item
field={`${item.field}.key`}
noStyle
rules={[{ required: true, message: '请输入变量名' }]}
>
<Input placeholder="变量名" />
</Form.Item>
<span className="text-gray-400">=</span>
<Form.Item
field={`${item.field}.value`}
noStyle
rules={[{ required: true, message: '请输入变量值' }]}
>
<Input placeholder="变量值" />
</Form.Item>
<Button
icon={<IconDelete />}
status="danger"
onClick={() => remove(index)}
/>
</div>
))}
<Button
type="dashed"
long
onClick={() => add()}
icon={<IconPlus />}
>
</Button>
</div>
)}
</Form.List>
</Form>
</Modal>
);
}
export default DeployModal;

View File

@@ -1,17 +1,8 @@
import { List, Space, Tag } from '@arco-design/web-react';
// 部署记录类型定义
interface DeployRecord {
id: number;
branch: string;
env: string;
commit: string;
status: 'success' | 'running' | 'failed' | 'pending';
createdAt: string;
}
import type { Deployment } from '../../types';
interface DeployRecordItemProps {
item: DeployRecord;
item: Deployment;
isSelected: boolean;
onSelect: (id: number) => void;
}
@@ -22,11 +13,8 @@ function DeployRecordItem({
onSelect,
}: DeployRecordItemProps) {
// 状态标签渲染函数
const getStatusTag = (status: DeployRecord['status']) => {
const statusMap: Record<
DeployRecord['status'],
{ color: string; text: string }
> = {
const getStatusTag = (status: Deployment['status']) => {
const statusMap: Record<string, { color: string; text: string }> = {
success: { color: 'green', text: '成功' },
running: { color: 'blue', text: '运行中' },
failed: { color: 'red', text: '失败' },
@@ -67,7 +55,7 @@ function DeployRecordItem({
#{item.id}
</span>
<span className="text-gray-600 text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{item.commit}
{item.commitHash?.substring(0, 7)}
</span>
</div>
}
@@ -79,7 +67,7 @@ function DeployRecordItem({
<span className="font-medium text-gray-700">{item.branch}</span>
</span>
<span className="text-sm text-gray-500">
: {getEnvTag(item.env)}
: {getEnvTag(item.env || 'unknown')}
</span>
<span className="text-sm text-gray-500">
: {getStatusTag(item.status)}

View File

@@ -6,29 +6,18 @@ import {
} from '@arco-design/web-react/icon';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Step } from '../../types';
// 流水线步骤类型定义(更新为与后端一致)
interface PipelineStep {
id: number;
name: string;
description?: string;
order: number;
script: string; // 执行的脚本命令
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
pipelineId: number;
interface StepWithEnabled extends Step {
enabled: boolean;
}
interface PipelineStepItemProps {
step: PipelineStep;
step: StepWithEnabled;
index: number;
pipelineId: number;
onToggle: (pipelineId: number, stepId: number, enabled: boolean) => void;
onEdit: (pipelineId: number, step: PipelineStep) => void;
onEdit: (pipelineId: number, step: StepWithEnabled) => void;
onDelete: (pipelineId: number, stepId: number) => void;
}

View File

@@ -40,52 +40,23 @@ import {
import { useState } from 'react';
import { useParams } from 'react-router';
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
import type { Project } from '../types';
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 DeployRecord {
id: number;
branch: string;
env: string;
commit: string;
status: 'success' | 'running' | 'failed' | 'pending';
createdAt: string;
}
// 流水线步骤类型定义(更新为与后端一致)
interface PipelineStep {
id: number;
name: string;
description?: string;
order: number;
script: string; // 执行的脚本命令
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
pipelineId: number;
interface StepWithEnabled extends Step {
enabled: boolean;
}
// 流水线类型定义
interface Pipeline {
id: number;
name: string;
description: string;
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
projectId?: number;
steps?: PipelineStep[];
interface PipelineWithEnabled extends Pipeline {
steps?: StepWithEnabled[];
enabled: boolean;
}
function ProjectDetailPage() {
const [detail, setDetail] = useState<Project | null>();
@@ -97,44 +68,19 @@ function ProjectDetailPage() {
}),
);
const [selectedRecordId, setSelectedRecordId] = useState<number>(1);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [pipelines, setPipelines] = useState<PipelineWithEnabled[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedPipelineId, setSelectedPipelineId] = useState<number>(0);
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
const [editingStep, setEditingStep] = useState<StepWithEnabled | null>(null);
const [editingPipelineId, setEditingPipelineId] = useState<number | null>(
null,
);
const [pipelineModalVisible, setPipelineModalVisible] = useState(false);
const [editingPipeline, setEditingPipeline] = useState<Pipeline | null>(null);
const [editingPipeline, setEditingPipeline] = useState<PipelineWithEnabled | null>(null);
const [form] = Form.useForm();
const [pipelineForm] = Form.useForm();
const [deployRecords, _setDeployRecords] = useState<DeployRecord[]>([
{
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 的部署记录,避免可能的冲突
{
id: 4,
branch: 'main',
env: 'production',
commit: '4e8b6a5c3',
status: 'success',
createdAt: '2024-09-07 10:15:30',
},
]);
const [deployRecords, setDeployRecords] = useState<Deployment[]>([]);
const [deployModalVisible, setDeployModalVisible] = useState(false);
const { id } = useParams();
useAsyncEffect(async () => {
@@ -164,52 +110,33 @@ function ProjectDetailPage() {
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 logs: Record<number, string[]> = {
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] 🔄 正在推送镜像...',
],
// 移除了 ID 为 3 的模拟数据,避免可能的冲突
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] ✅ 部署成功',
],
const record = deployRecords.find((r) => r.id === recordId);
if (!record || !record.buildLog) {
return ['暂无日志记录'];
}
return record.buildLog.split('\n');
};
return logs[recordId] || ['暂无日志记录'];
// 触发部署
const handleDeploy = () => {
setDeployModalVisible(true);
};
// 添加新流水线
@@ -220,7 +147,7 @@ function ProjectDetailPage() {
};
// 编辑流水线
const handleEditPipeline = (pipeline: Pipeline) => {
const handleEditPipeline = (pipeline: PipelineWithEnabled) => {
setEditingPipeline(pipeline);
pipelineForm.setFieldsValue({
name: pipeline.name,
@@ -263,7 +190,7 @@ function ProjectDetailPage() {
};
// 复制流水线
const handleCopyPipeline = async (pipeline: Pipeline) => {
const handleCopyPipeline = async (pipeline: PipelineWithEnabled) => {
Modal.confirm({
title: '确认复制',
content: '确定要复制这个流水线吗?',
@@ -404,7 +331,7 @@ function ProjectDetailPage() {
};
// 编辑步骤
const handleEditStep = (pipelineId: number, step: PipelineStep) => {
const handleEditStep = (pipelineId: number, step: StepWithEnabled) => {
setEditingStep(step);
setEditingPipelineId(pipelineId);
form.setFieldsValue({
@@ -573,11 +500,8 @@ function ProjectDetailPage() {
const buildLogs = getBuildLogs(selectedRecordId);
// 简单的状态标签渲染函数(仅用于构建日志区域)
const renderStatusTag = (status: DeployRecord['status']) => {
const statusMap: Record<
DeployRecord['status'],
{ color: string; text: string }
> = {
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: '失败' },
@@ -588,7 +512,7 @@ function ProjectDetailPage() {
};
// 渲染部署记录项
const renderDeployRecordItem = (item: DeployRecord, _index: number) => {
const renderDeployRecordItem = (item: Deployment, _index: number) => {
const isSelected = item.id === selectedRecordId;
return (
<DeployRecordItem
@@ -609,17 +533,21 @@ function ProjectDetailPage() {
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button type="primary" icon={<IconPlayArrow />}>
<Button type="primary" icon={<IconPlayArrow />} onClick={handleDeploy}>
</Button>
</div>
<div className="bg-white p-6 rounded-lg shadow-md flex-1">
<Tabs type="line" size="large">
<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">
<div className="flex items-center justify-between">
<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>
@@ -627,7 +555,7 @@ function ProjectDetailPage() {
</Button>
</div>
<div className="h-full overflow-y-auto">
<div className="flex-1 overflow-y-auto min-h-0">
{deployRecords.length > 0 ? (
<List
className="bg-white rounded-lg border"
@@ -644,8 +572,8 @@ function ProjectDetailPage() {
</div>
{/* 右侧构建日志 */}
<div className="col-span-3 bg-white rounded-lg border h-full overflow-hidden">
<div className="p-4 border-b bg-gray-50">
<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">
@@ -665,8 +593,8 @@ function ProjectDetailPage() {
)}
</div>
</div>
<div className="p-4 h-full overflow-y-auto">
<div className="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm h-full overflow-y-auto">
<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}`}
@@ -706,7 +634,7 @@ function ProjectDetailPage() {
key={pipeline.id}
className={`cursor-pointer transition-all duration-200 ${
isSelected
? 'bg-blue-50 border-l-4 border-blue-500 border-blue-300'
? 'bg-blue-50 border-l-4 border-blue-500'
: 'hover:bg-gray-50 border-gray-200'
}`}
onClick={() => setSelectedPipelineId(pipeline.id)}
@@ -821,6 +749,7 @@ function ProjectDetailPage() {
const selectedPipeline = pipelines.find(
(p) => p.id === selectedPipelineId,
);
if (!selectedPipeline) return null;
return (
<>
<div className="p-4 border-b bg-gray-50">
@@ -966,6 +895,25 @@ function ProjectDetailPage() {
</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>
);
}

View File

@@ -1,5 +1,5 @@
import { type APIResponse, net } from '@shared';
import type { Pipeline, Project, Step } from '../types';
import type { Branch, Commit, Deployment, Pipeline, Project, Step } from '../types';
class DetailService {
async getProject(id: string) {
@@ -17,6 +17,14 @@ class DetailService {
return data;
}
// 获取项目的部署记录
async getDeployments(projectId: number) {
const { data } = await net.request<any>({
url: `/api/deployments?projectId=${projectId}`,
});
return data.data;
}
// 创建流水线
async createPipeline(
pipeline: Omit<
@@ -120,6 +128,39 @@ class DetailService {
});
return data;
}
// 获取项目的提交记录
async getCommits(projectId: number, branch?: string) {
const { data } = await net.request<APIResponse<Commit[]>>({
url: `/api/git/commits?projectId=${projectId}${branch ? `&branch=${branch}` : ''}`,
});
return data;
}
// 获取项目的分支列表
async getBranches(projectId: number) {
const { data } = await net.request<APIResponse<Branch[]>>({
url: `/api/git/branches?projectId=${projectId}`,
});
return data;
}
// 创建部署
async createDeployment(deployment: {
projectId: number;
pipelineId: number;
branch: string;
commitHash: string;
commitMessage: string;
env?: string;
}) {
const { data } = await net.request<APIResponse<Deployment>>({
url: '/api/deployments',
method: 'POST',
data: deployment,
});
return data;
}
}
export const detailService = new DetailService();

View File

@@ -45,3 +45,48 @@ export interface Pipeline {
projectId?: number;
steps?: Step[];
}
export interface Deployment {
id: number;
branch: string;
env?: string;
status: string;
commitHash?: string;
commitMessage?: string;
buildLog?: string;
startedAt: string;
finishedAt?: string;
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
projectId: number;
}
export interface Commit {
sha: string;
commit: {
message: string;
author: {
name: string;
email: string;
date: string;
};
};
html_url: string;
}
export interface Branch {
name: string;
commit: {
id: string;
message: string;
url: string;
author: {
name: string;
email: string;
date: string;
};
};
}