From 378070179f88c4662a17ce44ba23bd562bb3b2d8 Mon Sep 17 00:00:00 2001 From: hurole <1192163814@qq.com> Date: Sun, 23 Nov 2025 12:03:11 +0800 Subject: [PATCH] feat: Introduce DTOs for API validation and new deployment features, including a Git controller and UI components. --- apps/server/controllers/auth/dto.ts | 7 + apps/server/controllers/auth/index.ts | 7 +- apps/server/controllers/deployment/dto.ts | 19 ++ apps/server/controllers/deployment/index.ts | 44 +++- apps/server/controllers/git/dto.ts | 13 + apps/server/controllers/git/index.ts | 113 ++++++++ apps/server/controllers/index.ts | 1 + .../pipeline/{schema.ts => dto.ts} | 0 apps/server/controllers/pipeline/index.ts | 2 +- .../controllers/project/{schema.ts => dto.ts} | 0 apps/server/controllers/project/index.ts | 2 +- apps/server/controllers/step/dto.ts | 103 ++++++++ apps/server/controllers/step/index.ts | 110 +------- apps/server/controllers/user/dto.ts | 26 ++ apps/server/controllers/user/index.ts | 32 ++- apps/server/libs/gitea.ts | 48 ++++ apps/server/middlewares/router.ts | 6 +- apps/server/prisma/data/dev.db | Bin 32768 -> 32768 bytes .../project/detail/components/DeployModal.tsx | 243 ++++++++++++++++++ .../detail/components/DeployRecordItem.tsx | 24 +- .../detail/components/PipelineStepItem.tsx | 19 +- apps/web/src/pages/project/detail/index.tsx | 204 ++++++--------- apps/web/src/pages/project/detail/service.ts | 43 +++- apps/web/src/pages/project/types.ts | 45 ++++ 24 files changed, 809 insertions(+), 302 deletions(-) create mode 100644 apps/server/controllers/auth/dto.ts create mode 100644 apps/server/controllers/deployment/dto.ts create mode 100644 apps/server/controllers/git/dto.ts create mode 100644 apps/server/controllers/git/index.ts rename apps/server/controllers/pipeline/{schema.ts => dto.ts} (100%) rename apps/server/controllers/project/{schema.ts => dto.ts} (100%) create mode 100644 apps/server/controllers/step/dto.ts create mode 100644 apps/server/controllers/user/dto.ts create mode 100644 apps/web/src/pages/project/detail/components/DeployModal.tsx diff --git a/apps/server/controllers/auth/dto.ts b/apps/server/controllers/auth/dto.ts new file mode 100644 index 0000000..d8dd1a4 --- /dev/null +++ b/apps/server/controllers/auth/dto.ts @@ -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; diff --git a/apps/server/controllers/auth/index.ts b/apps/server/controllers/auth/index.ts index 552c060..87a06ee 100644 --- a/apps/server/controllers/auth/index.ts +++ b/apps/server/controllers/auth/index.ts @@ -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; -} diff --git a/apps/server/controllers/deployment/dto.ts b/apps/server/controllers/deployment/dto.ts new file mode 100644 index 0000000..b839718 --- /dev/null +++ b/apps/server/controllers/deployment/dto.ts @@ -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; +export type CreateDeploymentInput = z.infer; diff --git a/apps/server/controllers/deployment/index.ts b/apps/server/controllers/deployment/index.ts index 1d16742..ff7a32a 100644 --- a/apps/server/controllers/deployment/index.ts +++ b/apps/server/controllers/deployment/index.ts @@ -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 { page, pageSize, projectId } = listDeploymentsQuerySchema.parse(ctx.query); + const where: Prisma.DeploymentWhereInput = { + valid: 1, + }; + + if (projectId) { + where.projectId = projectId; + } + const result = await prisma.deployment.findMany({ - where: { - valid: 1, - }, - take: Number(pageSize), - skip: (Number(page) - 1) * Number(pageSize), + 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; } } diff --git a/apps/server/controllers/git/dto.ts b/apps/server/controllers/git/dto.ts new file mode 100644 index 0000000..1bc29b6 --- /dev/null +++ b/apps/server/controllers/git/dto.ts @@ -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; +export type GetBranchesQuery = z.infer; diff --git a/apps/server/controllers/git/index.ts b/apps/server/controllers/git/index.ts new file mode 100644 index 0000000..c3edda4 --- /dev/null +++ b/apps/server/controllers/git/index.ts @@ -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); + } +} diff --git a/apps/server/controllers/index.ts b/apps/server/controllers/index.ts index dcc4985..051649a 100644 --- a/apps/server/controllers/index.ts +++ b/apps/server/controllers/index.ts @@ -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'; diff --git a/apps/server/controllers/pipeline/schema.ts b/apps/server/controllers/pipeline/dto.ts similarity index 100% rename from apps/server/controllers/pipeline/schema.ts rename to apps/server/controllers/pipeline/dto.ts diff --git a/apps/server/controllers/pipeline/index.ts b/apps/server/controllers/pipeline/index.ts index bb1f977..c13a1af 100644 --- a/apps/server/controllers/pipeline/index.ts +++ b/apps/server/controllers/pipeline/index.ts @@ -8,7 +8,7 @@ import { updatePipelineSchema, pipelineIdSchema, listPipelinesQuerySchema, -} from './schema.ts'; +} from './dto.ts'; @Controller('/pipelines') export class PipelineController { diff --git a/apps/server/controllers/project/schema.ts b/apps/server/controllers/project/dto.ts similarity index 100% rename from apps/server/controllers/project/schema.ts rename to apps/server/controllers/project/dto.ts diff --git a/apps/server/controllers/project/index.ts b/apps/server/controllers/project/index.ts index 17091b3..3f08fdb 100644 --- a/apps/server/controllers/project/index.ts +++ b/apps/server/controllers/project/index.ts @@ -8,7 +8,7 @@ import { updateProjectSchema, listProjectQuerySchema, projectIdSchema, -} from './schema.ts'; +} from './dto.ts'; @Controller('/projects') export class ProjectController { diff --git a/apps/server/controllers/step/dto.ts b/apps/server/controllers/step/dto.ts new file mode 100644 index 0000000..cc1d63e --- /dev/null +++ b/apps/server/controllers/step/dto.ts @@ -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; +export type UpdateStepInput = z.infer; +export type StepIdParams = z.infer; +export type ListStepsQuery = z.infer; diff --git a/apps/server/controllers/step/index.ts b/apps/server/controllers/step/index.ts index 7d33c6e..a87c47a 100644 --- a/apps/server/controllers/step/index.ts +++ b/apps/server/controllers/step/index.ts @@ -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; -type UpdateStepInput = z.infer; -type StepIdParams = z.infer; -type ListStepsQuery = z.infer; +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, diff --git a/apps/server/controllers/user/dto.ts b/apps/server/controllers/user/dto.ts new file mode 100644 index 0000000..7c01902 --- /dev/null +++ b/apps/server/controllers/user/dto.ts @@ -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; +export type UpdateUserInput = z.infer; +export type SearchUserQuery = z.infer; diff --git a/apps/server/controllers/user/index.ts b/apps/server/controllers/user/index.ts index c24089b..347a0dd 100644 --- a/apps/server/controllers/user/index.ts +++ b/apps/server/controllers/user/index.ts @@ -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()) ); } diff --git a/apps/server/libs/gitea.ts b/apps/server/libs/gitea.ts index 605132e..7f8bc3b 100644 --- a/apps/server/libs/gitea.ts +++ b/apps/server/libs/gitea.ts @@ -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 = { 'Content-Type': 'application/json', diff --git a/apps/server/middlewares/router.ts b/apps/server/middlewares/router.ts index 374d53e..8740c12 100644 --- a/apps/server/middlewares/router.ts +++ b/apps/server/middlewares/router.ts @@ -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 ]); // 输出注册的路由信息 diff --git a/apps/server/prisma/data/dev.db b/apps/server/prisma/data/dev.db index a34b14c2db4e155ea258c710f4ea4ad5893c07b6..0ad9f40df7f1aee38640f7f3c75770a5251cdaef 100644 GIT binary patch delta 1284 zcmb7DO>Em_7`F2RO(1qb>ZGXrWI~guP!h+ElhuGYfK)L}B52)l8czJB^@tr4J3~d2 zlG3DYx~wVd){VikpB6P@YO^k*bW7L{)5K{);=&b#zb^>~ICAAn(?Ui83CXgp_xE|; z_j$8fOEzm+7$k*Ho_muNHr`vIpSDql30kBb)FbL}?V0}9s7%J@BnDl_93vxccRcyN z=#9zahN0<#-#;cBzSzk)@EIUB?jKL5k_q77iSc14%dosR5b(0>Xdoo;jKCfA@yzfm z3?neie>(LE-2h7GZ2?N{(F;6DJtXY(8bSX~|3ZIHuhsfJWqPR3`aBttHIR^1VCi#M z2goA^(CqY2gv+sOcRgX~0q^nHe$A;&A)|JT}C!Zw-UK%_$ExJaCE;3RPF;n&A>$)M1$?BK}R0)3= zHX4p`K#H=G6c>4x3xs(P2!fb23^;}t`EWQKXQhKF5Eo-cGWD{Sl66HK5}>K zs|}d{qHBcf*U{|P%^R62TDk)pC8yKtL4_>(ynyCc;p`^-v|?_pnf3XE9M%2Z9G{{0 zmO!7~H|xvj!fNwUZaYef;s{))wVG=cr_8(4c)1#?T!WV{;94IgrS_M_R&^4UH&Ho< zlhD-$%rr{o);v0My>+dKQ=8|mpxK4UHtt=BRaly9Zq#AhB>D8>+ya4l$|<7(BIN?^dZWmPGL!&pfYj$ zQ-s?wv~wlp9IW5fR88@u(yAw#mJ`w*$PDWXF#Nwj?sfK}vPd@Er|&?w4_q&_mh+uH h?|5Vt3qO(QZNmvXD+C!I!!Uc>>~TKUX7NyF^AB*C>WTmW delta 247 zcmZo@U}|V!njp<+GEv5v(PU%75`HdbzOxMciTs>=XE!S?;!&#|855U z@BFX$FYzDUETAxtUz3wrmN752JU+N2wSbv}lUbAz%;M%?mgNk{EJ)4C%uD5EV^-vt zd|h8&gqfY0S(G!hIJJnEfq{XM|1bmpVW0&){1YeWPCjh!$iW~06lY*y0Se~w^EI+C z@-j$D>Ng5 void; + onOk: () => void; + pipelines: Pipeline[]; + projectId: number; +} + +function DeployModal({ + visible, + onCancel, + onOk, + pipelines, + projectId, +}: DeployModalProps) { + const [form] = Form.useForm(); + const [branches, setBranches] = useState([]); + const [commits, setCommits] = useState([]); + 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 ( + +
+ + + + + + + + + + + + +
环境变量
+ + {(fields, { add, remove }) => ( +
+ {fields.map((item, index) => ( +
+ + + + = + + + +
+ ))} + +
+ )} +
+
+
+ ); +} + +export default DeployModal; diff --git a/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx b/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx index 42a98f5..ac3e261 100644 --- a/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx +++ b/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx @@ -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 = { success: { color: 'green', text: '成功' }, running: { color: 'blue', text: '运行中' }, failed: { color: 'red', text: '失败' }, @@ -67,7 +55,7 @@ function DeployRecordItem({ #{item.id} - {item.commit} + {item.commitHash?.substring(0, 7)} } @@ -79,7 +67,7 @@ function DeployRecordItem({ {item.branch} - 环境: {getEnvTag(item.env)} + 环境: {getEnvTag(item.env || 'unknown')} 状态: {getStatusTag(item.status)} diff --git a/apps/web/src/pages/project/detail/components/PipelineStepItem.tsx b/apps/web/src/pages/project/detail/components/PipelineStepItem.tsx index 8640f58..3d6d8fd 100644 --- a/apps/web/src/pages/project/detail/components/PipelineStepItem.tsx +++ b/apps/web/src/pages/project/detail/components/PipelineStepItem.tsx @@ -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; } diff --git a/apps/web/src/pages/project/detail/index.tsx b/apps/web/src/pages/project/detail/index.tsx index 5ee7e4c..cdfab7c 100644 --- a/apps/web/src/pages/project/detail/index.tsx +++ b/apps/web/src/pages/project/detail/index.tsx @@ -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(); @@ -97,44 +68,19 @@ function ProjectDetailPage() { }), ); const [selectedRecordId, setSelectedRecordId] = useState(1); - const [pipelines, setPipelines] = useState([]); + const [pipelines, setPipelines] = useState([]); const [editModalVisible, setEditModalVisible] = useState(false); const [selectedPipelineId, setSelectedPipelineId] = useState(0); - const [editingStep, setEditingStep] = useState(null); + const [editingStep, setEditingStep] = useState(null); const [editingPipelineId, setEditingPipelineId] = useState( null, ); const [pipelineModalVisible, setPipelineModalVisible] = useState(false); - const [editingPipeline, setEditingPipeline] = useState(null); + const [editingPipeline, setEditingPipeline] = useState(null); const [form] = Form.useForm(); const [pipelineForm] = Form.useForm(); - const [deployRecords, _setDeployRecords] = useState([ - { - id: 1, - branch: 'main', - env: 'development', - commit: '1d1224ae1', - status: 'success', - createdAt: '2024-09-07 14:30:25', - }, - { - id: 2, - branch: 'develop', - env: 'staging', - commit: '2f4b5c8e9', - status: 'running', - createdAt: '2024-09-07 13:45:12', - }, - // 移除了 ID 为 3 的部署记录,避免可能的冲突 - { - id: 4, - branch: 'main', - env: 'production', - commit: '4e8b6a5c3', - status: 'success', - createdAt: '2024-09-07 10:15:30', - }, - ]); + const [deployRecords, setDeployRecords] = useState([]); + 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 = { - 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] ✅ 部署成功', - ], - }; - return logs[recordId] || ['暂无日志记录']; + const record = deployRecords.find((r) => r.id === recordId); + if (!record || !record.buildLog) { + return ['暂无日志记录']; + } + return record.buildLog.split('\n'); + }; + + // 触发部署 + 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 = { 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 ( 自动化地部署项目 - -
- +
+
{/* 左侧部署记录列表 */} -
-
+
+
共 {deployRecords.length} 条部署记录 @@ -627,7 +555,7 @@ function ProjectDetailPage() { 刷新
-
+
{deployRecords.length > 0 ? ( {/* 右侧构建日志 */} -
-
+
+
@@ -665,8 +593,8 @@ function ProjectDetailPage() { )}
-
-
+
+
{buildLogs.map((log: string, index: number) => (
setSelectedPipelineId(pipeline.id)} @@ -821,6 +749,7 @@ function ProjectDetailPage() { const selectedPipeline = pipelines.find( (p) => p.id === selectedPipelineId, ); + if (!selectedPipeline) return null; return ( <>
@@ -966,6 +895,25 @@ function ProjectDetailPage() {
+ + 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)} + />
); } diff --git a/apps/web/src/pages/project/detail/service.ts b/apps/web/src/pages/project/detail/service.ts index adcfe45..705b0fc 100644 --- a/apps/web/src/pages/project/detail/service.ts +++ b/apps/web/src/pages/project/detail/service.ts @@ -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({ + 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>({ + url: `/api/git/commits?projectId=${projectId}${branch ? `&branch=${branch}` : ''}`, + }); + return data; + } + + // 获取项目的分支列表 + async getBranches(projectId: number) { + const { data } = await net.request>({ + 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>({ + url: '/api/deployments', + method: 'POST', + data: deployment, + }); + return data; + } } export const detailService = new DetailService(); diff --git a/apps/web/src/pages/project/types.ts b/apps/web/src/pages/project/types.ts index 5f0890e..bf5cf41 100644 --- a/apps/web/src/pages/project/types.ts +++ b/apps/web/src/pages/project/types.ts @@ -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; + }; + }; +}