feat: Introduce DTOs for API validation and new deployment features, including a Git controller and UI components.
This commit is contained in:
7
apps/server/controllers/auth/dto.ts
Normal file
7
apps/server/controllers/auth/dto.ts
Normal 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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
apps/server/controllers/deployment/dto.ts
Normal file
19
apps/server/controllers/deployment/dto.ts
Normal 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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/server/controllers/git/dto.ts
Normal file
13
apps/server/controllers/git/dto.ts
Normal 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>;
|
||||
113
apps/server/controllers/git/index.ts
Normal file
113
apps/server/controllers/git/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
updatePipelineSchema,
|
||||
pipelineIdSchema,
|
||||
listPipelinesQuerySchema,
|
||||
} from './schema.ts';
|
||||
} from './dto.ts';
|
||||
|
||||
@Controller('/pipelines')
|
||||
export class PipelineController {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
updateProjectSchema,
|
||||
listProjectQuerySchema,
|
||||
projectIdSchema,
|
||||
} from './schema.ts';
|
||||
} from './dto.ts';
|
||||
|
||||
@Controller('/projects')
|
||||
export class ProjectController {
|
||||
|
||||
103
apps/server/controllers/step/dto.ts
Normal file
103
apps/server/controllers/step/dto.ts
Normal 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>;
|
||||
@@ -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,
|
||||
|
||||
26
apps/server/controllers/user/dto.ts
Normal file
26
apps/server/controllers/user/dto.ts
Normal 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>;
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
243
apps/web/src/pages/project/detail/components/DeployModal.tsx
Normal file
243
apps/web/src/pages/project/detail/components/DeployModal.tsx
Normal 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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user