Compare commits

...

7 Commits

Author SHA1 Message Date
b5c550f5c5 feat(project): add workspace directory configuration and management
- Add projectDir field to Project model for workspace directory management
- Implement workspace directory creation, validation and Git initialization
- Add workspace status query endpoint with directory info and Git status
- Create GitManager for Git repository operations (clone, branch, commit info)
- Add PathValidator for secure path validation and traversal attack prevention
- Implement execution queue with concurrency control for build tasks

- Refactor project list UI to remove edit/delete actions from cards
- Add project settings tab in detail page with edit/delete functionality
- Add icons to all tabs (History, Code, Settings)
- Implement time formatting with dayjs in YYYY-MM-DD HH:mm:ss format
- Display all timestamps using browser's local timezone

- Update PipelineRunner to use workspace directory for command execution
- Add workspace status card showing directory path, size, Git info
- Enhance CreateProjectModal with repository URL validation
2026-01-03 00:54:57 +08:00
9897bd04c2 feat(server): 支持稀疏检出路径并完善部署执行队列
- 在部署DTO中添加sparseCheckoutPaths字段支持稀疏检出路径
- 数据模型Deployment新增稀疏检出路径字段及相关数据库映射
- 部署创建时支持设置稀疏检出路径字段
- 部署重试接口实现,支持复制原始部署记录并加入执行队列
- 新增流水线模板初始化与基于模板创建流水线接口
- 优化应用初始化流程,确保执行队列和流水线模板正确加载
- 添加启动日志,提示执行队列初始化完成
2025-12-12 23:21:26 +08:00
73240d94b1 refactor: remove pipeline DTO and type definitions 2025-11-23 12:29:42 +08:00
378070179f feat: Introduce DTOs for API validation and new deployment features, including a Git controller and UI components. 2025-11-23 12:03:11 +08:00
02b7c3edb2 refactor(prisma): 统一导入prisma客户端方式
- 所有控制器中从libs/prisma.ts导入prisma替代旧的libs/db.ts
- 规范了step模块中zod验证架构的格式与换行
- 生成Prisma客户端及类型定义文件,包含browser、client、commonInputTypes、enums和内部类文件
- 优化listStepsQuerySchema默认参数与链式调用格式
- 保持代码风格一致,提升可维护性和类型安全性
2025-11-22 01:06:53 +08:00
f8697b87e1 完成流水线控制器重构和相关功能改进 2025-11-21 23:30:05 +08:00
fd0cf782c4 feat: 增加 pipeline和 deployment 2025-09-21 21:38:42 +08:00
74 changed files with 15968 additions and 967 deletions

7
apps/server/README.md Normal file
View File

@@ -0,0 +1,7 @@
## 表
- user
- project
- pipeline
- deployment
- runner

View File

@@ -1,13 +1,32 @@
import Koa from 'koa'; import Koa from 'koa';
import { initMiddlewares } from './middlewares/index.ts'; import { initMiddlewares } from './middlewares/index.ts';
import { log } from './libs/logger.ts'; import { log } from './libs/logger.ts';
import { ExecutionQueue } from './libs/execution-queue.ts';
import { initializePipelineTemplates } from './libs/pipeline-template.ts';
const app = new Koa(); // 初始化应用
async function initializeApp() {
// 初始化流水线模板
await initializePipelineTemplates();
initMiddlewares(app); // 初始化执行队列
const executionQueue = ExecutionQueue.getInstance();
await executionQueue.initialize();
const PORT = process.env.PORT || 3001; const app = new Koa();
app.listen(PORT, () => { initMiddlewares(app);
log.info('APP', 'Server started at port %d', PORT);
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
log.info('APP', 'Server started at port %d', PORT);
log.info('QUEUE', 'Execution queue initialized');
});
}
// 启动应用
initializeApp().catch(error => {
console.error('Failed to start application:', error);
process.exit(1);
}); });

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

@@ -1,8 +1,9 @@
import type { Context } from 'koa'; import type { Context } from 'koa';
import { Controller, Get, Post } from '../../decorators/route.ts'; import { Controller, Get, Post } from '../../decorators/route.ts';
import prisma from '../../libs/db.ts'; import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts'; import { log } from '../../libs/logger.ts';
import { gitea } from '../../libs/gitea.ts'; import { gitea } from '../../libs/gitea.ts';
import { loginSchema } from './dto.ts';
@Controller('/auth') @Controller('/auth')
export class AuthController { export class AuthController {
@@ -20,7 +21,7 @@ export class AuthController {
if (ctx.session.user) { if (ctx.session.user) {
return 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 } = const { access_token, refresh_token, expires_in } =
await gitea.getToken(code); await gitea.getToken(code);
const giteaAuth = { const giteaAuth = {
@@ -81,7 +82,3 @@ export class AuthController {
return ctx.session?.user; return ctx.session?.user;
} }
} }
interface LoginRequestBody {
code: string;
}

View File

@@ -0,0 +1,20 @@
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(),
sparseCheckoutPaths: z.string().optional(), // 添加稀疏检出路径字段
});
export type ListDeploymentsQuery = z.infer<typeof listDeploymentsQuerySchema>;
export type CreateDeploymentInput = z.infer<typeof createDeploymentSchema>;

View File

@@ -0,0 +1,119 @@
import { Controller, Get, Post } from '../../decorators/route.ts';
import type { Prisma } from '../../generated/client.ts';
import { prisma } from '../../libs/prisma.ts';
import type { Context } from 'koa';
import { listDeploymentsQuerySchema, createDeploymentSchema } from './dto.ts';
import { ExecutionQueue } from '../../libs/execution-queue.ts';
@Controller('/deployments')
export class DeploymentController {
@Get('')
async list(ctx: Context) {
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,
take: pageSize,
skip: (page - 1) * pageSize,
orderBy: {
createdAt: 'desc',
},
});
const total = await prisma.deployment.count({ where });
return {
data: result,
page,
pageSize,
total,
};
}
@Post('')
async create(ctx: Context) {
const body = createDeploymentSchema.parse(ctx.request.body);
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',
sparseCheckoutPaths: body.sparseCheckoutPaths || '', // 添加稀疏检出路径
buildLog: '',
createdBy: 'system', // TODO: get from user
updatedBy: 'system',
valid: 1,
},
});
// 将新创建的部署任务添加到执行队列
const executionQueue = ExecutionQueue.getInstance();
await executionQueue.addTask(result.id, result.pipelineId);
return result;
}
// 添加重新执行部署的接口
@Post('/:id/retry')
async retry(ctx: Context) {
const { id } = ctx.params;
// 获取原始部署记录
const originalDeployment = await prisma.deployment.findUnique({
where: { id: Number(id) }
});
if (!originalDeployment) {
ctx.status = 404;
ctx.body = {
code: 404,
message: '部署记录不存在',
data: null,
timestamp: Date.now()
};
return;
}
// 创建一个新的部署记录,复制原始记录的信息
const newDeployment = await prisma.deployment.create({
data: {
branch: originalDeployment.branch,
commitHash: originalDeployment.commitHash,
commitMessage: originalDeployment.commitMessage,
status: 'pending',
projectId: originalDeployment.projectId,
pipelineId: originalDeployment.pipelineId,
env: originalDeployment.env,
sparseCheckoutPaths: originalDeployment.sparseCheckoutPaths,
buildLog: '',
createdBy: 'system',
updatedBy: 'system',
valid: 1,
},
});
// 将新创建的部署任务添加到执行队列
const executionQueue = ExecutionQueue.getInstance();
await executionQueue.addTask(newDeployment.id, newDeployment.pipelineId);
ctx.body = {
code: 0,
message: '重新执行任务已创建',
data: newDeployment,
timestamp: Date.now()
};
}
}

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

@@ -2,3 +2,7 @@
export { ProjectController } from './project/index.ts'; export { ProjectController } from './project/index.ts';
export { UserController } from './user/index.ts'; export { UserController } from './user/index.ts';
export { AuthController } from './auth/index.ts'; 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

@@ -0,0 +1,40 @@
import { z } from 'zod';
// 定义验证架构
export const createPipelineSchema = z.object({
name: z.string({
message: '流水线名称必须是字符串',
}).min(1, { message: '流水线名称不能为空' }).max(100, { message: '流水线名称不能超过100个字符' }),
description: z.string({
message: '流水线描述必须是字符串',
}).max(500, { message: '流水线描述不能超过500个字符' }).optional(),
projectId: z.number({
message: '项目ID必须是数字',
}).int().positive({ message: '项目ID必须是正整数' }).optional(),
});
export const updatePipelineSchema = z.object({
name: z.string({
message: '流水线名称必须是字符串',
}).min(1, { message: '流水线名称不能为空' }).max(100, { message: '流水线名称不能超过100个字符' }).optional(),
description: z.string({
message: '流水线描述必须是字符串',
}).max(500, { message: '流水线描述不能超过500个字符' }).optional(),
});
export const pipelineIdSchema = z.object({
id: z.coerce.number().int().positive({ message: '流水线 ID 必须是正整数' }),
});
export const listPipelinesQuerySchema = z.object({
projectId: z.coerce.number().int().positive({ message: '项目ID必须是正整数' }).optional(),
}).optional();
// 类型
export type CreatePipelineInput = z.infer<typeof createPipelineSchema>;
export type UpdatePipelineInput = z.infer<typeof updatePipelineSchema>;
export type PipelineIdParams = z.infer<typeof pipelineIdSchema>;
export type ListPipelinesQuery = z.infer<typeof listPipelinesQuerySchema>;

View File

@@ -0,0 +1,242 @@
import type { Context } from 'koa';
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import { getAvailableTemplates, createPipelineFromTemplate } from '../../libs/pipeline-template.ts';
import {
createPipelineSchema,
updatePipelineSchema,
pipelineIdSchema,
listPipelinesQuerySchema,
} from './dto.ts';
@Controller('/pipelines')
export class PipelineController {
// GET /api/pipelines - 获取流水线列表
@Get('')
async list(ctx: Context) {
const query = listPipelinesQuerySchema.parse(ctx.query);
const whereCondition: any = {
valid: 1,
};
// 如果提供了项目ID参数
if (query?.projectId) {
whereCondition.projectId = query.projectId;
}
const pipelines = await prisma.pipeline.findMany({
where: whereCondition,
include: {
steps: {
where: {
valid: 1,
},
orderBy: {
order: 'asc',
},
},
},
});
return pipelines;
}
// GET /api/pipelines/templates - 获取可用的流水线模板
@Get('/templates')
async getTemplates(ctx: Context) {
try {
const templates = await getAvailableTemplates();
return templates;
} catch (error) {
console.error('Failed to get templates:', error);
throw new BusinessError('获取模板失败', 3002, 500);
}
}
// GET /api/pipelines/:id - 获取单个流水线
@Get('/:id')
async get(ctx: Context) {
const { id } = pipelineIdSchema.parse(ctx.params);
const pipeline = await prisma.pipeline.findFirst({
where: {
id,
valid: 1,
},
include: {
steps: {
where: {
valid: 1,
},
orderBy: {
order: 'asc',
},
},
},
});
if (!pipeline) {
throw new BusinessError('流水线不存在', 3001, 404);
}
return pipeline;
}
// POST /api/pipelines - 创建流水线
@Post('')
async create(ctx: Context) {
const validatedData = createPipelineSchema.parse(ctx.request.body);
const pipeline = await prisma.pipeline.create({
data: {
name: validatedData.name,
description: validatedData.description || '',
projectId: validatedData.projectId,
createdBy: 'system',
updatedBy: 'system',
valid: 1,
},
});
log.info('pipeline', 'Created new pipeline: %s', pipeline.name);
return pipeline;
}
// POST /api/pipelines/from-template - 基于模板创建流水线
@Post('/from-template')
async createFromTemplate(ctx: Context) {
try {
const { templateId, projectId, name, description } = ctx.request.body as {
templateId: number;
projectId: number;
name: string;
description?: string;
};
// 验证必要参数
if (!templateId || !projectId || !name) {
throw new BusinessError('缺少必要参数', 3003, 400);
}
// 基于模板创建流水线
const newPipelineId = await createPipelineFromTemplate(
templateId,
projectId,
name,
description || ''
);
// 返回新创建的流水线
const pipeline = await prisma.pipeline.findUnique({
where: { id: newPipelineId },
include: {
steps: {
where: {
valid: 1,
},
orderBy: {
order: 'asc',
},
},
},
});
if (!pipeline) {
throw new BusinessError('创建流水线失败', 3004, 500);
}
log.info('pipeline', 'Created pipeline from template: %s', pipeline.name);
return pipeline;
} catch (error) {
console.error('Failed to create pipeline from template:', error);
if (error instanceof BusinessError) {
throw error;
}
throw new BusinessError('基于模板创建流水线失败', 3005, 500);
}
}
// PUT /api/pipelines/:id - 更新流水线
@Put('/:id')
async update(ctx: Context) {
const { id } = pipelineIdSchema.parse(ctx.params);
const validatedData = updatePipelineSchema.parse(ctx.request.body);
// 检查流水线是否存在
const existingPipeline = await prisma.pipeline.findFirst({
where: {
id,
valid: 1,
},
});
if (!existingPipeline) {
throw new BusinessError('流水线不存在', 3001, 404);
}
// 只更新提供的字段
const updateData: any = {
updatedBy: 'system',
};
if (validatedData.name !== undefined) {
updateData.name = validatedData.name;
}
if (validatedData.description !== undefined) {
updateData.description = validatedData.description;
}
const pipeline = await prisma.pipeline.update({
where: { id },
data: updateData,
});
log.info('pipeline', 'Updated pipeline: %s', pipeline.name);
return pipeline;
}
// DELETE /api/pipelines/:id - 删除流水线(软删除)
@Delete('/:id')
async destroy(ctx: Context) {
const { id } = pipelineIdSchema.parse(ctx.params);
// 检查流水线是否存在
const existingPipeline = await prisma.pipeline.findFirst({
where: {
id,
valid: 1,
},
});
if (!existingPipeline) {
throw new BusinessError('流水线不存在', 3001, 404);
}
// 软删除:将 valid 设置为 0
await prisma.pipeline.update({
where: { id },
data: {
valid: 0,
updatedBy: 'system',
},
});
// 同时软删除关联的步骤
await prisma.step.updateMany({
where: { pipelineId: id },
data: {
valid: 0,
updatedBy: 'system',
},
});
log.info('pipeline', 'Deleted pipeline: %s', existingPipeline.name);
// RESTful 删除成功返回 204 No Content
ctx.status = 204;
return null;
}
}

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { projectDirSchema } from '../../libs/path-validator.js';
/** /**
* *
@@ -15,6 +16,8 @@ export const createProjectSchema = z.object({
repository: z.string({ repository: z.string({
message: '仓库地址必须是字符串', message: '仓库地址必须是字符串',
}).url({ message: '请输入有效的仓库地址' }).min(1, { message: '仓库地址不能为空' }), }).url({ message: '请输入有效的仓库地址' }).min(1, { message: '仓库地址不能为空' }),
projectDir: projectDirSchema,
}); });
/** /**

View File

@@ -1,14 +1,15 @@
import type { Context } from 'koa'; import type { Context } from 'koa';
import prisma from '../../libs/db.ts'; import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts'; import { log } from '../../libs/logger.ts';
import { BusinessError } from '../../middlewares/exception.ts'; import { BusinessError } from '../../middlewares/exception.ts';
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts'; import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
import { GitManager } from '../../libs/git-manager.ts';
import { import {
createProjectSchema, createProjectSchema,
updateProjectSchema, updateProjectSchema,
listProjectQuerySchema, listProjectQuerySchema,
projectIdSchema, projectIdSchema,
} from './schema.ts'; } from './dto.ts';
@Controller('/projects') @Controller('/projects')
export class ProjectController { export class ProjectController {
@@ -37,7 +38,7 @@ export class ProjectController {
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}) }),
]); ]);
return { return {
@@ -47,7 +48,7 @@ export class ProjectController {
limit: query?.limit || 10, limit: query?.limit || 10,
total, total,
totalPages: Math.ceil(total / (query?.limit || 10)), totalPages: Math.ceil(total / (query?.limit || 10)),
} },
}; };
} }
@@ -67,7 +68,48 @@ export class ProjectController {
throw new BusinessError('项目不存在', 1002, 404); throw new BusinessError('项目不存在', 1002, 404);
} }
return project; // 获取工作目录状态信息
let workspaceStatus = null;
if (project.projectDir) {
try {
const status = await GitManager.checkWorkspaceStatus(
project.projectDir,
);
let size = 0;
let gitInfo = null;
if (status.exists && !status.isEmpty) {
size = await GitManager.getDirectorySize(project.projectDir);
}
if (status.hasGit) {
gitInfo = await GitManager.getGitInfo(project.projectDir);
}
workspaceStatus = {
...status,
size,
gitInfo,
};
} catch (error) {
log.error(
'project',
'Failed to get workspace status for project %s: %s',
project.name,
(error as Error).message,
);
// 即使获取状态失败,也返回项目信息
workspaceStatus = {
status: 'error',
error: (error as Error).message,
};
}
}
return {
...project,
workspaceStatus,
};
} }
// POST /api/projects - 创建项目 // POST /api/projects - 创建项目
@@ -75,18 +117,36 @@ export class ProjectController {
async create(ctx: Context) { async create(ctx: Context) {
const validatedData = createProjectSchema.parse(ctx.request.body); const validatedData = createProjectSchema.parse(ctx.request.body);
// 检查工作目录是否已被其他项目使用
const existingProject = await prisma.project.findFirst({
where: {
projectDir: validatedData.projectDir,
valid: 1,
},
});
if (existingProject) {
throw new BusinessError('该工作目录已被其他项目使用', 1003, 400);
}
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { data: {
name: validatedData.name, name: validatedData.name,
description: validatedData.description || '', description: validatedData.description || '',
repository: validatedData.repository, repository: validatedData.repository,
projectDir: validatedData.projectDir,
createdBy: 'system', createdBy: 'system',
updatedBy: 'system', updatedBy: 'system',
valid: 1, valid: 1,
}, },
}); });
log.info('project', 'Created new project: %s', project.name); log.info(
'project',
'Created new project: %s with projectDir: %s',
project.name,
project.projectDir,
);
return project; return project;
} }

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

@@ -0,0 +1,181 @@
import type { Context } from 'koa';
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 {
createStepSchema,
updateStepSchema,
stepIdSchema,
listStepsQuerySchema,
} from './dto.ts';
@Controller('/steps')
export class StepController {
// GET /api/steps - 获取步骤列表
@Get('')
async list(ctx: Context) {
const query = listStepsQuerySchema.parse(ctx.query);
const whereCondition: any = {
valid: 1,
};
// 如果提供了流水线ID参数
if (query?.pipelineId) {
whereCondition.pipelineId = query.pipelineId;
}
const [total, steps] = await Promise.all([
prisma.step.count({ where: whereCondition }),
prisma.step.findMany({
where: whereCondition,
skip: query ? (query.page - 1) * query.limit : 0,
take: query?.limit,
orderBy: {
order: 'asc',
},
}),
]);
return {
data: steps,
pagination: {
page: query?.page || 1,
limit: query?.limit || 10,
total,
totalPages: Math.ceil(total / (query?.limit || 10)),
},
};
}
// GET /api/steps/:id - 获取单个步骤
@Get(':id')
async show(ctx: Context) {
const { id } = stepIdSchema.parse(ctx.params);
const step = await prisma.step.findFirst({
where: {
id,
valid: 1,
},
});
if (!step) {
throw new BusinessError('步骤不存在', 2001, 404);
}
return step;
}
// POST /api/steps - 创建步骤
@Post('')
async create(ctx: Context) {
const validatedData = createStepSchema.parse(ctx.request.body);
// 检查关联的流水线是否存在
const pipeline = await prisma.pipeline.findFirst({
where: {
id: validatedData.pipelineId,
valid: 1,
},
});
if (!pipeline) {
throw new BusinessError('关联的流水线不存在', 2002, 404);
}
const step = await prisma.step.create({
data: {
name: validatedData.name,
order: validatedData.order,
script: validatedData.script,
pipelineId: validatedData.pipelineId,
createdBy: 'system',
updatedBy: 'system',
valid: 1,
},
});
log.info('step', 'Created new step: %s', step.name);
return step;
}
// PUT /api/steps/:id - 更新步骤
@Put(':id')
async update(ctx: Context) {
const { id } = stepIdSchema.parse(ctx.params);
const validatedData = updateStepSchema.parse(ctx.request.body);
// 检查步骤是否存在
const existingStep = await prisma.step.findFirst({
where: {
id,
valid: 1,
},
});
if (!existingStep) {
throw new BusinessError('步骤不存在', 2001, 404);
}
// 只更新提供的字段
const updateData: any = {
updatedBy: 'system',
};
if (validatedData.name !== undefined) {
updateData.name = validatedData.name;
}
if (validatedData.description !== undefined) {
updateData.description = validatedData.description;
}
if (validatedData.order !== undefined) {
updateData.order = validatedData.order;
}
if (validatedData.script !== undefined) {
updateData.script = validatedData.script;
}
const step = await prisma.step.update({
where: { id },
data: updateData,
});
log.info('step', 'Updated step: %s', step.name);
return step;
}
// DELETE /api/steps/:id - 删除步骤(软删除)
@Delete(':id')
async destroy(ctx: Context) {
const { id } = stepIdSchema.parse(ctx.params);
// 检查步骤是否存在
const existingStep = await prisma.step.findFirst({
where: {
id,
valid: 1,
},
});
if (!existingStep) {
throw new BusinessError('步骤不存在', 2001, 404);
}
// 软删除:将 valid 设置为 0
await prisma.step.update({
where: { id },
data: {
valid: 0,
updatedBy: 'system',
},
});
log.info('step', 'Deleted step: %s', existingStep.name);
// RESTful 删除成功返回 204 No Content
ctx.status = 204;
return null;
}
}

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

View File

@@ -0,0 +1,44 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }
export * as $Enums from './enums.ts'
export * from './enums.ts';
/**
* Model Project
*
*/
export type Project = Prisma.ProjectModel
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Pipeline
*
*/
export type Pipeline = Prisma.PipelineModel
/**
* Model Step
*
*/
export type Step = Prisma.StepModel
/**
* Model Deployment
*
*/
export type Deployment = Prisma.DeploymentModel

View File

@@ -0,0 +1,66 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import * as $Class from "./internal/class.ts"
import * as Prisma from "./internal/prismaNamespace.ts"
export * as $Enums from './enums.ts'
export * from "./enums.ts"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Projects
* const projects = await prisma.project.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model Project
*
*/
export type Project = Prisma.ProjectModel
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Pipeline
*
*/
export type Pipeline = Prisma.PipelineModel
/**
* Model Step
*
*/
export type Step = Prisma.StepModel
/**
* Model Deployment
*
*/
export type Deployment = Prisma.DeploymentModel

View File

@@ -0,0 +1,402 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import type * as Prisma from "./internal/prismaNamespace.ts"
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}

View File

@@ -0,0 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}

View File

@@ -0,0 +1,230 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* Please import the `PrismaClient` class from the `client.ts` file instead.
*/
import * as runtime from "@prisma/client/runtime/client"
import type * as Prisma from "./prismaNamespace.ts"
const config: runtime.GetPrismaClientConfig = {
"previewFeatures": [],
"clientVersion": "7.0.0",
"engineVersion": "0c19ccc313cf9911a90d99d2ac2eb0280c76c513",
"activeProvider": "sqlite",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel Project {\n id Int @id @default(autoincrement())\n name String\n description String?\n repository String\n projectDir String @unique // 项目工作目录路径(必填)\n // Relations\n deployments Deployment[]\n pipelines Pipeline[]\n\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n}\n\nmodel User {\n id Int @id @default(autoincrement())\n username String\n login String\n email String\n avatar_url String?\n active Boolean @default(true)\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String @default(\"system\")\n updatedBy String @default(\"system\")\n}\n\nmodel Pipeline {\n id Int @id @default(autoincrement())\n name String\n description String?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n // Relations\n projectId Int?\n Project Project? @relation(fields: [projectId], references: [id])\n steps Step[]\n}\n\nmodel Step {\n id Int @id @default(autoincrement())\n name String\n order Int\n script String // 执行的脚本命令\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n pipelineId Int\n pipeline Pipeline @relation(fields: [pipelineId], references: [id])\n}\n\nmodel Deployment {\n id Int @id @default(autoincrement())\n branch String\n env String?\n status String // pending, running, success, failed, cancelled\n commitHash String?\n commitMessage String?\n buildLog String?\n sparseCheckoutPaths String? // 稀疏检出路径用于monorepo项目\n startedAt DateTime @default(now())\n finishedAt DateTime?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n projectId Int\n Project Project? @relation(fields: [projectId], references: [id])\n pipelineId Int\n}\n",
"runtimeDataModel": {
"models": {},
"enums": {},
"types": {}
}
}
config.runtimeDataModel = JSON.parse("{\"models\":{\"Project\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"repository\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectDir\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deployments\",\"kind\":\"object\",\"type\":\"Deployment\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelines\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToProject\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar_url\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Pipeline\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"PipelineToProject\"},{\"name\":\"steps\",\"kind\":\"object\",\"type\":\"Step\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Step\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"order\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"script\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"pipeline\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Deployment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"branch\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"env\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitMessage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"buildLog\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sparseCheckoutPaths\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"finishedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {
const { Buffer } = await import('node:buffer')
const wasmArray = Buffer.from(wasmBase64, 'base64')
return new WebAssembly.Module(wasmArray)
}
config.compilerWasm = {
getRuntime: async () => await import("@prisma/client/runtime/query_compiler_bg.sqlite.mjs"),
getQueryCompilerWasmModule: async () => {
const { wasm } = await import("@prisma/client/runtime/query_compiler_bg.sqlite.wasm-base64.mjs")
return await decodeBase64AsWasm(wasm)
}
}
export type LogOptions<ClientOptions extends Prisma.PrismaClientOptions> =
'log' extends keyof ClientOptions ? ClientOptions['log'] extends Array<Prisma.LogLevel | Prisma.LogDefinition> ? Prisma.GetEvents<ClientOptions['log']> : never : never
export interface PrismaClientConstructor {
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Projects
* const projects = await prisma.project.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
new <
Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
LogOpts extends LogOptions<Options> = LogOptions<Options>,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
>(options: Prisma.Subset<Options, Prisma.PrismaClientOptions> ): PrismaClient<LogOpts, OmitOpts, ExtArgs>
}
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Projects
* const projects = await prisma.project.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
export interface PrismaClient<
in LogOpts extends Prisma.LogLevel = never,
in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = undefined,
in out ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
> {
[K: symbol]: { types: Prisma.TypeMap<ExtArgs>['other'] }
$on<V extends LogOpts>(eventType: V, callback: (event: V extends 'query' ? Prisma.QueryEvent : Prisma.LogEvent) => void): PrismaClient;
/**
* Connect with the database
*/
$connect(): runtime.Types.Utils.JsPromise<void>;
/**
* Disconnect from the database
*/
$disconnect(): runtime.Types.Utils.JsPromise<void>;
/**
* Executes a prepared raw query and returns the number of affected rows.
* @example
* ```
* const result = await prisma.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};`
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$executeRaw<T = unknown>(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise<number>;
/**
* Executes a raw query and returns the number of affected rows.
* Susceptible to SQL injections, see documentation.
* @example
* ```
* const result = await prisma.$executeRawUnsafe('UPDATE User SET cool = $1 WHERE email = $2 ;', true, 'user@email.com')
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$executeRawUnsafe<T = unknown>(query: string, ...values: any[]): Prisma.PrismaPromise<number>;
/**
* Performs a prepared raw query and returns the `SELECT` data.
* @example
* ```
* const result = await prisma.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};`
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$queryRaw<T = unknown>(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise<T>;
/**
* Performs a raw query and returns the `SELECT` data.
* Susceptible to SQL injections, see documentation.
* @example
* ```
* const result = await prisma.$queryRawUnsafe('SELECT * FROM User WHERE id = $1 OR email = $2;', 1, 'user@email.com')
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$queryRawUnsafe<T = unknown>(query: string, ...values: any[]): Prisma.PrismaPromise<T>;
/**
* Allows the running of a sequence of read/write operations that are guaranteed to either succeed or fail as a whole.
* @example
* ```
* const [george, bob, alice] = await prisma.$transaction([
* prisma.user.create({ data: { name: 'George' } }),
* prisma.user.create({ data: { name: 'Bob' } }),
* prisma.user.create({ data: { name: 'Alice' } }),
* ])
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/concepts/components/prisma-client/transactions).
*/
$transaction<P extends Prisma.PrismaPromise<any>[]>(arg: [...P], options?: { isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise<runtime.Types.Utils.UnwrapTuple<P>>
$transaction<R>(fn: (prisma: Omit<PrismaClient, runtime.ITXClientDenyList>) => runtime.Types.Utils.JsPromise<R>, options?: { maxWait?: number, timeout?: number, isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise<R>
$extends: runtime.Types.Extensions.ExtendsHook<"extends", Prisma.TypeMapCb<OmitOpts>, ExtArgs, runtime.Types.Utils.Call<Prisma.TypeMapCb<OmitOpts>, {
extArgs: ExtArgs
}>>
/**
* `prisma.project`: Exposes CRUD operations for the **Project** model.
* Example usage:
* ```ts
* // Fetch zero or more Projects
* const projects = await prisma.project.findMany()
* ```
*/
get project(): Prisma.ProjectDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.user`: Exposes CRUD operations for the **User** model.
* Example usage:
* ```ts
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*/
get user(): Prisma.UserDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.pipeline`: Exposes CRUD operations for the **Pipeline** model.
* Example usage:
* ```ts
* // Fetch zero or more Pipelines
* const pipelines = await prisma.pipeline.findMany()
* ```
*/
get pipeline(): Prisma.PipelineDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.step`: Exposes CRUD operations for the **Step** model.
* Example usage:
* ```ts
* // Fetch zero or more Steps
* const steps = await prisma.step.findMany()
* ```
*/
get step(): Prisma.StepDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.deployment`: Exposes CRUD operations for the **Deployment** model.
* Example usage:
* ```ts
* // Fetch zero or more Deployments
* const deployments = await prisma.deployment.findMany()
* ```
*/
get deployment(): Prisma.DeploymentDelegate<ExtArgs, { omit: OmitOpts }>;
}
export function getPrismaClientClass(): PrismaClientConstructor {
return runtime.getPrismaClient(config) as unknown as PrismaClientConstructor
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.ts'
export type * from './prismaNamespace.ts'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
Project: 'Project',
User: 'User',
Pipeline: 'Pipeline',
Step: 'Step',
Deployment: 'Deployment'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = {
Serializable: 'Serializable'
} as const
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const ProjectScalarFieldEnum = {
id: 'id',
name: 'name',
description: 'description',
repository: 'repository',
projectDir: 'projectDir',
valid: 'valid',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdBy: 'createdBy',
updatedBy: 'updatedBy'
} as const
export type ProjectScalarFieldEnum = (typeof ProjectScalarFieldEnum)[keyof typeof ProjectScalarFieldEnum]
export const UserScalarFieldEnum = {
id: 'id',
username: 'username',
login: 'login',
email: 'email',
avatar_url: 'avatar_url',
active: 'active',
valid: 'valid',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdBy: 'createdBy',
updatedBy: 'updatedBy'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const PipelineScalarFieldEnum = {
id: 'id',
name: 'name',
description: 'description',
valid: 'valid',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdBy: 'createdBy',
updatedBy: 'updatedBy',
projectId: 'projectId'
} as const
export type PipelineScalarFieldEnum = (typeof PipelineScalarFieldEnum)[keyof typeof PipelineScalarFieldEnum]
export const StepScalarFieldEnum = {
id: 'id',
name: 'name',
order: 'order',
script: 'script',
valid: 'valid',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdBy: 'createdBy',
updatedBy: 'updatedBy',
pipelineId: 'pipelineId'
} as const
export type StepScalarFieldEnum = (typeof StepScalarFieldEnum)[keyof typeof StepScalarFieldEnum]
export const DeploymentScalarFieldEnum = {
id: 'id',
branch: 'branch',
env: 'env',
status: 'status',
commitHash: 'commitHash',
commitMessage: 'commitMessage',
buildLog: 'buildLog',
sparseCheckoutPaths: 'sparseCheckoutPaths',
startedAt: 'startedAt',
finishedAt: 'finishedAt',
valid: 'valid',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdBy: 'createdBy',
updatedBy: 'updatedBy',
projectId: 'projectId',
pipelineId: 'pipelineId'
} as const
export type DeploymentScalarFieldEnum = (typeof DeploymentScalarFieldEnum)[keyof typeof DeploymentScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]

View File

@@ -0,0 +1,16 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/Project.ts'
export type * from './models/User.ts'
export type * from './models/Pipeline.ts'
export type * from './models/Step.ts'
export type * from './models/Deployment.ts'
export type * from './commonInputTypes.ts'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
import { PrismaClient } from '../generated/prisma/index.js'
const prismaClientSingleton = () => {
return new PrismaClient();
};
export default prismaClientSingleton();

View File

@@ -0,0 +1,266 @@
import { PipelineRunner } from '../runners/index.ts';
import { prisma } from './prisma.ts';
// 存储正在运行的部署任务
const runningDeployments = new Set<number>();
// 存储待执行的任务队列
const pendingQueue: Array<{
deploymentId: number;
pipelineId: number;
}> = [];
// 定时器ID
let pollingTimer: NodeJS.Timeout | null = null;
// 轮询间隔(毫秒)
const POLLING_INTERVAL = 30000; // 30秒
/**
* 执行队列管理器
*/
export class ExecutionQueue {
private static instance: ExecutionQueue;
private isProcessing = false;
private isPolling = false;
private constructor() {}
/**
* 获取执行队列的单例实例
*/
public static getInstance(): ExecutionQueue {
if (!ExecutionQueue.instance) {
ExecutionQueue.instance = new ExecutionQueue();
}
return ExecutionQueue.instance;
}
/**
* 初始化执行队列,包括恢复未完成的任务
*/
public async initialize(): Promise<void> {
console.log('Initializing execution queue...');
// 恢复未完成的任务
await this.recoverPendingDeployments();
// 启动定时轮询
this.startPolling();
console.log('Execution queue initialized');
}
/**
* 从数据库中恢复未完成的部署任务
*/
private async recoverPendingDeployments(): Promise<void> {
try {
console.log('Recovering pending deployments from database...');
// 查询数据库中状态为pending的部署任务
const pendingDeployments = await prisma.deployment.findMany({
where: {
status: 'pending',
valid: 1,
},
select: {
id: true,
pipelineId: true,
},
});
console.log(`Found ${pendingDeployments.length} pending deployments`);
// 将这些任务添加到执行队列中
for (const deployment of pendingDeployments) {
await this.addTask(deployment.id, deployment.pipelineId);
}
console.log('Pending deployments recovery completed');
} catch (error) {
console.error('Failed to recover pending deployments:', error);
}
}
/**
* 启动定时轮询机制
*/
private startPolling(): void {
if (this.isPolling) {
console.log('Polling is already running');
return;
}
this.isPolling = true;
console.log(`Starting polling with interval ${POLLING_INTERVAL}ms`);
// 立即执行一次检查
this.checkPendingDeployments();
// 设置定时器定期检查
pollingTimer = setInterval(() => {
this.checkPendingDeployments();
}, POLLING_INTERVAL);
}
/**
* 停止定时轮询机制
*/
public stopPolling(): void {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
this.isPolling = false;
console.log('Polling stopped');
}
}
/**
* 检查数据库中的待处理部署任务
*/
private async checkPendingDeployments(): Promise<void> {
try {
console.log('Checking for pending deployments in database...');
// 查询数据库中状态为pending的部署任务
const pendingDeployments = await prisma.deployment.findMany({
where: {
status: 'pending',
valid: 1,
},
select: {
id: true,
pipelineId: true,
},
});
console.log(
`Found ${pendingDeployments.length} pending deployments in polling`,
);
// 检查这些任务是否已经在队列中,如果没有则添加
for (const deployment of pendingDeployments) {
// 检查是否已经在运行队列中
if (!runningDeployments.has(deployment.id)) {
console.log(
`Adding deployment ${deployment.id} to queue from polling`,
);
await this.addTask(deployment.id, deployment.pipelineId);
}
}
} catch (error) {
console.error('Failed to check pending deployments:', error);
}
}
/**
* 将部署任务添加到执行队列
* @param deploymentId 部署ID
* @param pipelineId 流水线ID
*/
public async addTask(
deploymentId: number,
pipelineId: number,
): Promise<void> {
// 检查是否已经在运行队列中
if (runningDeployments.has(deploymentId)) {
console.log(`Deployment ${deploymentId} is already queued or running`);
return;
}
// 添加到运行队列
runningDeployments.add(deploymentId);
// 添加到待执行队列
pendingQueue.push({ deploymentId, pipelineId });
// 开始处理队列(如果尚未开始)
if (!this.isProcessing) {
this.processQueue();
}
}
/**
* 处理执行队列中的任务
*/
private async processQueue(): Promise<void> {
this.isProcessing = true;
while (pendingQueue.length > 0) {
const task = pendingQueue.shift();
if (task) {
try {
// 执行流水线
await this.executePipeline(task.deploymentId, task.pipelineId);
} catch (error) {
console.error('执行流水线失败:', error);
// 这里可以添加更多的错误处理逻辑
} finally {
// 从运行队列中移除
runningDeployments.delete(task.deploymentId);
}
}
// 添加一个小延迟以避免过度占用资源
await new Promise((resolve) => setTimeout(resolve, 100));
}
this.isProcessing = false;
}
/**
* 执行流水线
* @param deploymentId 部署ID
* @param pipelineId 流水线ID
*/
private async executePipeline(
deploymentId: number,
pipelineId: number,
): Promise<void> {
try {
// 获取部署信息以获取项目和 projectDir
const deployment = await prisma.deployment.findUnique({
where: { id: deploymentId },
include: {
Project: true,
},
});
if (!deployment || !deployment.Project) {
throw new Error(
`Deployment ${deploymentId} or associated project not found`,
);
}
if (!deployment.Project.projectDir) {
throw new Error(
`项目 "${deployment.Project.name}" 未配置工作目录,无法执行流水线`,
);
}
const runner = new PipelineRunner(
deploymentId,
deployment.Project.projectDir,
);
await runner.run(pipelineId);
} catch (error) {
console.error('执行流水线失败:', error);
// 错误处理可以在这里添加,比如更新部署状态为失败
throw error;
}
}
/**
* 获取队列状态
*/
public getQueueStatus(): {
pendingCount: number;
runningCount: number;
} {
return {
pendingCount: pendingQueue.length,
runningCount: runningDeployments.size,
};
}
}

View File

@@ -0,0 +1,280 @@
/**
* Git 管理器
* 封装 Git 操作:克隆、更新、分支切换等
*/
import { $ } from 'zx';
import fs from 'node:fs/promises';
import path from 'node:path';
import { log } from './logger';
/**
* 工作目录状态
*/
export const WorkspaceDirStatus = {
NOT_CREATED: 'not_created', // 目录不存在
EMPTY: 'empty', // 目录存在但为空
NO_GIT: 'no_git', // 目录存在但不是 Git 仓库
READY: 'ready', // 目录存在且包含 Git 仓库
} as const;
export type WorkspaceDirStatus =
(typeof WorkspaceDirStatus)[keyof typeof WorkspaceDirStatus];
/**
* 工作目录状态信息
*/
export interface WorkspaceStatus {
status: WorkspaceDirStatus;
exists: boolean;
isEmpty?: boolean;
hasGit?: boolean;
}
/**
* Git仓库信息
*/
export interface GitInfo {
branch?: string;
lastCommit?: string;
lastCommitMessage?: string;
}
/**
* Git管理器类
*/
export class GitManager {
static readonly TAG = 'GitManager';
/**
* 检查工作目录状态
*/
static async checkWorkspaceStatus(dirPath: string): Promise<WorkspaceStatus> {
try {
// 检查目录是否存在
const stats = await fs.stat(dirPath);
if (!stats.isDirectory()) {
return {
status: WorkspaceDirStatus.NOT_CREATED,
exists: false,
};
}
// 检查目录是否为空
const files = await fs.readdir(dirPath);
if (files.length === 0) {
return {
status: WorkspaceDirStatus.EMPTY,
exists: true,
isEmpty: true,
};
}
// 检查是否包含 .git 目录
const gitDir = path.join(dirPath, '.git');
try {
const gitStats = await fs.stat(gitDir);
if (gitStats.isDirectory()) {
return {
status: WorkspaceDirStatus.READY,
exists: true,
isEmpty: false,
hasGit: true,
};
}
} catch {
return {
status: WorkspaceDirStatus.NO_GIT,
exists: true,
isEmpty: false,
hasGit: false,
};
}
return {
status: WorkspaceDirStatus.NO_GIT,
exists: true,
isEmpty: false,
hasGit: false,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
status: WorkspaceDirStatus.NOT_CREATED,
exists: false,
};
}
throw error;
}
}
/**
* 克隆仓库到指定目录
* @param repoUrl 仓库URL
* @param dirPath 目标目录
* @param branch 分支名
* @param token Gitea access token可选
*/
static async cloneRepository(
repoUrl: string,
dirPath: string,
branch: string,
token?: string,
): Promise<void> {
try {
log.info(
GitManager.TAG,
'Cloning repository: %s to %s (branch: %s)',
repoUrl,
dirPath,
branch,
);
// 如果提供了token嵌入到URL中
let cloneUrl = repoUrl;
if (token) {
const url = new URL(repoUrl);
url.username = token;
cloneUrl = url.toString();
}
// 使用 zx 执行 git clone浅克隆
$.verbose = false; // 禁止打印敏感信息
await $`git clone --depth 1 --branch ${branch} ${cloneUrl} ${dirPath}`;
$.verbose = true;
log.info(GitManager.TAG, 'Repository cloned successfully: %s', dirPath);
} catch (error) {
log.error(
GitManager.TAG,
'Failed to clone repository: %s to %s, error: %s',
repoUrl,
dirPath,
(error as Error).message,
);
throw new Error(`克隆仓库失败: ${(error as Error).message}`);
}
}
/**
* 更新已存在的仓库
* @param dirPath 仓库目录
* @param branch 目标分支
*/
static async updateRepository(
dirPath: string,
branch: string,
): Promise<void> {
try {
log.info(
GitManager.TAG,
'Updating repository: %s (branch: %s)',
dirPath,
branch,
);
$.verbose = false;
// 切换到仓库目录
const originalCwd = process.cwd();
process.chdir(dirPath);
try {
// 获取最新代码
await $`git fetch --depth 1 origin ${branch}`;
// 切换到目标分支
await $`git checkout ${branch}`;
// 拉取最新代码
await $`git pull origin ${branch}`;
log.info(
GitManager.TAG,
'Repository updated successfully: %s (branch: %s)',
dirPath,
branch,
);
} finally {
process.chdir(originalCwd);
$.verbose = true;
}
} catch (error) {
log.error(
GitManager.TAG,
'Failed to update repository: %s (branch: %s), error: %s',
dirPath,
branch,
(error as Error).message,
);
throw new Error(`更新仓库失败: ${(error as Error).message}`);
}
}
/**
* 获取Git仓库信息
*/
static async getGitInfo(dirPath: string): Promise<GitInfo> {
try {
const originalCwd = process.cwd();
process.chdir(dirPath);
try {
$.verbose = false;
const branchResult = await $`git branch --show-current`;
const commitResult = await $`git rev-parse --short HEAD`;
const messageResult = await $`git log -1 --pretty=%B`;
$.verbose = true;
return {
branch: branchResult.stdout.trim(),
lastCommit: commitResult.stdout.trim(),
lastCommitMessage: messageResult.stdout.trim(),
};
} finally {
process.chdir(originalCwd);
}
} catch (error) {
log.error(
GitManager.TAG,
'Failed to get git info: %s, error: %s',
dirPath,
(error as Error).message,
);
return {};
}
}
/**
* 创建目录(递归)
*/
static async ensureDirectory(dirPath: string): Promise<void> {
try {
await fs.mkdir(dirPath, { recursive: true });
log.info(GitManager.TAG, 'Directory created: %s', dirPath);
} catch (error) {
log.error(
GitManager.TAG,
'Failed to create directory: %s, error: %s',
dirPath,
(error as Error).message,
);
throw new Error(`创建目录失败: ${(error as Error).message}`);
}
}
/**
* 获取目录大小
*/
static async getDirectorySize(dirPath: string): Promise<number> {
try {
const { stdout } = await $`du -sb ${dirPath}`;
const size = Number.parseInt(stdout.split('\t')[0], 10);
return size;
} catch (error) {
log.error(
GitManager.TAG,
'Failed to get directory size: %s, error: %s',
dirPath,
(error as Error).message,
);
return 0;
}
}
}

View File

@@ -80,6 +80,54 @@ class Gitea {
return result; 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) { private getHeaders(accessToken?: string) {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -0,0 +1,67 @@
/**
* 路径验证工具
* 用于验证项目工作目录路径的合法性
*/
import path from 'node:path';
import { z } from 'zod';
/**
* 项目目录路径验证schema
*/
export const projectDirSchema = z
.string()
.min(1, '工作目录路径不能为空')
.refine(path.isAbsolute, '工作目录路径必须是绝对路径')
.refine((v) => !v.includes('..'), '不能包含路径遍历字符')
.refine((v) => !v.includes('~'), '不能包含用户目录符号')
.refine((v) => !/[<>:"|?*\x00-\x1f]/.test(v), '包含非法字符')
.refine((v) => path.normalize(v) === v, '路径格式不规范');
/**
* 验证路径格式
* @param dirPath 待验证的路径
* @returns 验证结果
*/
export function validateProjectDir(dirPath: string): {
valid: boolean;
error?: string;
} {
try {
projectDirSchema.parse(dirPath);
return { valid: true };
} catch (error) {
if (error instanceof z.ZodError) {
return { valid: false, error: error.issues[0].message };
}
return { valid: false, error: '路径验证失败' };
}
}
/**
* 检查路径是否为绝对路径
*/
export function isAbsolutePath(dirPath: string): boolean {
return path.isAbsolute(dirPath);
}
/**
* 检查路径是否包含非法字符
*/
export function hasIllegalCharacters(dirPath: string): boolean {
return /[<>:"|?*\x00-\x1f]/.test(dirPath);
}
/**
* 检查路径是否包含路径遍历
*/
export function hasPathTraversal(dirPath: string): boolean {
return dirPath.includes('..') || dirPath.includes('~');
}
/**
* 规范化路径
*/
export function normalizePath(dirPath: string): string {
return path.normalize(dirPath);
}

View File

@@ -0,0 +1,247 @@
import { prisma } from './prisma.ts';
// 默认流水线模板
export interface PipelineTemplate {
name: string;
description: string;
steps: Array<{
name: string;
order: number;
script: string;
}>;
}
// 系统默认的流水线模板
export const DEFAULT_PIPELINE_TEMPLATES: PipelineTemplate[] = [
{
name: 'Git Clone Pipeline',
description: '默认的Git克隆流水线用于从仓库克隆代码',
steps: [
{
name: 'Clone Repository',
order: 0,
script: '# 克隆指定commit的代码\ngit init\ngit remote add origin $REPOSITORY_URL\ngit fetch --depth 1 origin $COMMIT_HASH\ngit checkout -q FETCH_HEAD\n\n# 显示当前提交信息\ngit log --oneline -1',
},
{
name: 'Install Dependencies',
order: 1,
script: '# 安装项目依赖\nnpm install',
},
{
name: 'Run Tests',
order: 2,
script: '# 运行测试\nnpm test',
},
{
name: 'Build Project',
order: 3,
script: '# 构建项目\nnpm run build',
}
]
},
{
name: 'Sparse Checkout Pipeline',
description: '稀疏检出流水线适用于monorepo项目只获取指定目录的代码',
steps: [
{
name: 'Sparse Checkout Repository',
order: 0,
script: '# 进行稀疏检出指定目录的代码\ngit init\ngit remote add origin $REPOSITORY_URL\ngit config core.sparseCheckout true\necho "$SPARSE_CHECKOUT_PATHS" > .git/info/sparse-checkout\ngit fetch --depth 1 origin $COMMIT_HASH\ngit checkout -q FETCH_HEAD\n\n# 显示当前提交信息\ngit log --oneline -1',
},
{
name: 'Install Dependencies',
order: 1,
script: '# 安装项目依赖\nnpm install',
},
{
name: 'Run Tests',
order: 2,
script: '# 运行测试\nnpm test',
},
{
name: 'Build Project',
order: 3,
script: '# 构建项目\nnpm run build',
}
]
},
{
name: 'Simple Deploy Pipeline',
description: '简单的部署流水线,包含基本的构建和部署步骤',
steps: [
{
name: 'Clone Repository',
order: 0,
script: '# 克隆指定commit的代码\ngit init\ngit remote add origin $REPOSITORY_URL\ngit fetch --depth 1 origin $COMMIT_HASH\ngit checkout -q FETCH_HEAD',
},
{
name: 'Build and Deploy',
order: 1,
script: '# 构建并部署项目\nnpm run build\n\n# 部署到目标服务器\n# 这里可以添加具体的部署命令',
}
]
}
];
/**
* 初始化系统默认流水线模板
*/
export async function initializePipelineTemplates(): Promise<void> {
console.log('Initializing pipeline templates...');
try {
// 检查是否已经存在模板流水线
const existingTemplates = await prisma.pipeline.findMany({
where: {
name: {
in: DEFAULT_PIPELINE_TEMPLATES.map(template => template.name)
},
valid: 1
}
});
// 如果没有现有的模板,则创建默认模板
if (existingTemplates.length === 0) {
console.log('Creating default pipeline templates...');
for (const template of DEFAULT_PIPELINE_TEMPLATES) {
// 创建模板流水线使用负数ID表示模板
const pipeline = await prisma.pipeline.create({
data: {
name: template.name,
description: template.description,
createdBy: 'system',
updatedBy: 'system',
valid: 1,
projectId: null // 模板不属于任何特定项目
}
});
// 创建模板步骤
for (const step of template.steps) {
await prisma.step.create({
data: {
name: step.name,
order: step.order,
script: step.script,
pipelineId: pipeline.id,
createdBy: 'system',
updatedBy: 'system',
valid: 1
}
});
}
console.log(`Created template: ${template.name}`);
}
} else {
console.log('Pipeline templates already exist, skipping initialization');
}
console.log('Pipeline templates initialization completed');
} catch (error) {
console.error('Failed to initialize pipeline templates:', error);
throw error;
}
}
/**
* 获取所有可用的流水线模板
*/
export async function getAvailableTemplates(): Promise<Array<{id: number, name: string, description: string}>> {
try {
const templates = await prisma.pipeline.findMany({
where: {
projectId: null, // 模板流水线没有关联的项目
valid: 1
},
select: {
id: true,
name: true,
description: true
}
});
// 处理可能为null的description字段
return templates.map(template => ({
id: template.id,
name: template.name,
description: template.description || ''
}));
} catch (error) {
console.error('Failed to get pipeline templates:', error);
throw error;
}
}
/**
* 基于模板创建新的流水线
* @param templateId 模板ID
* @param projectId 项目ID
* @param pipelineName 新流水线名称
* @param pipelineDescription 新流水线描述
*/
export async function createPipelineFromTemplate(
templateId: number,
projectId: number,
pipelineName: string,
pipelineDescription: string
): Promise<number> {
try {
// 获取模板流水线及其步骤
const templatePipeline = await prisma.pipeline.findUnique({
where: {
id: templateId,
projectId: null, // 确保是模板流水线
valid: 1
},
include: {
steps: {
where: {
valid: 1
},
orderBy: {
order: 'asc'
}
}
}
});
if (!templatePipeline) {
throw new Error(`Template with id ${templateId} not found`);
}
// 创建新的流水线
const newPipeline = await prisma.pipeline.create({
data: {
name: pipelineName,
description: pipelineDescription,
projectId: projectId,
createdBy: 'system',
updatedBy: 'system',
valid: 1
}
});
// 复制模板步骤到新流水线
for (const templateStep of templatePipeline.steps) {
await prisma.step.create({
data: {
name: templateStep.name,
order: templateStep.order,
script: templateStep.script,
pipelineId: newPipeline.id,
createdBy: 'system',
updatedBy: 'system',
valid: 1
}
});
}
console.log(`Created pipeline from template ${templateId}: ${newPipeline.name}`);
return newPipeline.id;
} catch (error) {
console.error('Failed to create pipeline from template:', error);
throw error;
}
}

View File

@@ -0,0 +1,8 @@
import 'dotenv/config';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from '../generated/client.ts';
const connectionString = `${process.env.DATABASE_URL}`;
const adapter = new PrismaBetterSqlite3({ url: connectionString });
export const prisma = new PrismaClient({ adapter });

View File

@@ -5,7 +5,7 @@ import { Session } from './session.ts';
import { CORS } from './cors.ts'; import { CORS } from './cors.ts';
import { HttpLogger } from './logger.ts'; import { HttpLogger } from './logger.ts';
import type Koa from 'koa'; import type Koa from 'koa';
import { Authorization } from './Authorization.ts'; import { Authorization } from './authorization.ts';
/** /**
* 初始化中间件 * 初始化中间件

View File

@@ -6,6 +6,10 @@ import {
ProjectController, ProjectController,
UserController, UserController,
AuthController, AuthController,
DeploymentController,
PipelineController,
StepController,
GitController
} from '../controllers/index.ts'; } from '../controllers/index.ts';
import { log } from '../libs/logger.ts'; import { log } from '../libs/logger.ts';
@@ -38,6 +42,10 @@ export class Router implements Middleware {
ProjectController, ProjectController,
UserController, UserController,
AuthController, AuthController,
DeploymentController,
PipelineController,
StepController,
GitController
]); ]);
// 输出注册的路由信息 // 输出注册的路由信息

View File

@@ -12,13 +12,17 @@
"dependencies": { "dependencies": {
"@koa/cors": "^5.0.0", "@koa/cors": "^5.0.0",
"@koa/router": "^14.0.0", "@koa/router": "^14.0.0",
"@prisma/client": "^6.15.0", "@prisma/adapter-better-sqlite3": "^7.0.0",
"@prisma/client": "^7.0.0",
"better-sqlite3": "^12.4.5",
"dotenv": "^17.2.3",
"koa": "^3.0.1", "koa": "^3.0.1",
"koa-bodyparser": "^4.4.1", "koa-bodyparser": "^4.4.1",
"koa-session": "^7.0.2", "koa-session": "^7.0.2",
"pino": "^9.9.1", "pino": "^9.9.1",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"zod": "^4.1.5" "zod": "^4.1.5",
"zx": "^8.8.2"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node-ts": "^23.6.1", "@tsconfig/node-ts": "^23.6.1",
@@ -28,7 +32,7 @@
"@types/koa__cors": "^5.0.0", "@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4", "@types/koa__router": "^12.0.4",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"prisma": "^6.15.0", "prisma": "^7.0.0",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "^5.9.2" "typescript": "^5.9.2"
} }

View File

@@ -0,0 +1,19 @@
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
// the main entry for your schema
schema: 'prisma/schema.prisma',
// where migrations should be generated
// what script to run for "prisma db seed"
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts',
},
// The database URL
datasource: {
// Type Safe env() helper
// Does not replace the need for dotenv
url: env('DATABASE_URL'),
},
});

Binary file not shown.

View File

@@ -2,36 +2,29 @@
// learn more about it in the docs: https://pris.ly/d/prisma-schema // learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client"
output = "../generated/prisma" output = "../generated"
} }
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = env("DATABASE_URL")
} }
model Project { model Project {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
description String? description String?
repository String repository String
valid Int @default(1) projectDir String @unique // 项目工作目录路径(必填)
createdAt DateTime @default(now()) // Relations
updatedAt DateTime @updatedAt deployments Deployment[]
createdBy String pipelines Pipeline[]
updatedBy String
}
model Environment { valid Int @default(1)
id Int @id @default(autoincrement()) createdAt DateTime @default(now())
name String updatedAt DateTime @updatedAt
description String? createdBy String
valid Int @default(1) updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
} }
model User { model User {
@@ -47,3 +40,56 @@ model User {
createdBy String @default("system") createdBy String @default("system")
updatedBy String @default("system") updatedBy String @default("system")
} }
model Pipeline {
id Int @id @default(autoincrement())
name String
description String?
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
// Relations
projectId Int?
Project Project? @relation(fields: [projectId], references: [id])
steps Step[]
}
model Step {
id Int @id @default(autoincrement())
name String
order Int
script String // 执行的脚本命令
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
pipelineId Int
pipeline Pipeline @relation(fields: [pipelineId], references: [id])
}
model Deployment {
id Int @id @default(autoincrement())
branch String
env String?
status String // pending, running, success, failed, cancelled
commitHash String?
commitMessage String?
buildLog String?
sparseCheckoutPaths String? // 稀疏检出路径用于monorepo项目
startedAt DateTime @default(now())
finishedAt DateTime?
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
projectId Int
Project Project? @relation(fields: [projectId], references: [id])
pipelineId Int
}

View File

@@ -0,0 +1,3 @@
import { PipelineRunner } from './pipeline-runner';
export { PipelineRunner };

View File

@@ -0,0 +1,28 @@
// MQ集成接口设计 (暂不实现)
// 该接口用于将来通过消息队列触发流水线执行
export interface MQPipelineMessage {
deploymentId: number;
pipelineId: number;
// 其他可能需要的参数
triggerUser?: string;
environment?: string;
}
export interface MQRunnerInterface {
/**
* 发送流水线执行消息到MQ
* @param message 流水线执行消息
*/
sendPipelineExecutionMessage(message: MQPipelineMessage): Promise<void>;
/**
* 监听MQ消息并执行流水线
*/
listenForPipelineMessages(): void;
/**
* 停止监听MQ消息
*/
stopListening(): void;
}

View File

@@ -0,0 +1,304 @@
import { $ } from 'zx';
import { prisma } from '../libs/prisma.ts';
import type { Step } from '../generated/client.ts';
import { GitManager, WorkspaceDirStatus } from '../libs/git-manager.ts';
import { log } from '../libs/logger.ts';
export class PipelineRunner {
private readonly TAG = 'PipelineRunner';
private deploymentId: number;
private projectDir: string;
constructor(deploymentId: number, projectDir: string) {
this.deploymentId = deploymentId;
if (!projectDir) {
throw new Error('项目工作目录未配置,无法执行流水线');
}
this.projectDir = projectDir;
log.info(
this.TAG,
'PipelineRunner initialized with projectDir: %s',
this.projectDir,
);
}
/**
* 执行流水线
* @param pipelineId 流水线ID
*/
async run(pipelineId: number): Promise<void> {
// 获取流水线及其步骤
const pipeline = await prisma.pipeline.findUnique({
where: { id: pipelineId },
include: {
steps: { where: { valid: 1 }, orderBy: { order: 'asc' } },
Project: true, // 同时获取关联的项目信息
},
});
if (!pipeline) {
throw new Error(`Pipeline with id ${pipelineId} not found`);
}
// 获取部署信息
const deployment = await prisma.deployment.findUnique({
where: { id: this.deploymentId },
});
if (!deployment) {
throw new Error(`Deployment with id ${this.deploymentId} not found`);
}
let logs = '';
let hasError = false;
try {
// 准备工作目录(检查、克隆或更新)
logs += await this.prepareWorkspace(pipeline.Project, deployment.branch);
// 更新部署状态为running
await prisma.deployment.update({
where: { id: this.deploymentId },
data: { status: 'running', buildLog: logs },
});
// 依次执行每个步骤
for (const [index, step] of pipeline.steps.entries()) {
// 准备环境变量
const envVars = this.prepareEnvironmentVariables(pipeline, deployment);
// 记录开始执行步骤的日志
const startLog = `[${new Date().toISOString()}] 开始执行步骤 ${index + 1}/${pipeline.steps.length}: ${step.name}\n`;
logs += startLog;
// 实时更新日志
await prisma.deployment.update({
where: { id: this.deploymentId },
data: { buildLog: logs },
});
// 执行步骤
const stepLog = await this.executeStep(step, envVars);
logs += stepLog + '\n';
// 记录步骤执行完成的日志
const endLog = `[${new Date().toISOString()}] 步骤 "${step.name}" 执行完成\n`;
logs += endLog;
// 实时更新日志
await prisma.deployment.update({
where: { id: this.deploymentId },
data: { buildLog: logs },
});
}
} catch (error) {
hasError = true;
const errorMsg = `[${new Date().toISOString()}] Error: ${(error as Error).message}\n`;
logs += errorMsg;
log.error(
this.TAG,
'Pipeline execution failed: %s',
(error as Error).message,
);
// 记录错误日志
await prisma.deployment.update({
where: { id: this.deploymentId },
data: {
buildLog: logs,
status: 'failed',
finishedAt: new Date(),
},
});
throw error;
}
// 更新最终状态
if (!hasError) {
await prisma.deployment.update({
where: { id: this.deploymentId },
data: {
buildLog: logs,
status: 'success',
finishedAt: new Date(),
},
});
}
}
/**
* 准备工作目录:检查状态、克隆或更新代码
* @param project 项目信息
* @param branch 目标分支
* @returns 准备过程的日志
*/
private async prepareWorkspace(
project: any,
branch: string,
): Promise<string> {
let logs = '';
const timestamp = new Date().toISOString();
try {
logs += `[${timestamp}] 检查工作目录状态: ${this.projectDir}\n`;
// 检查工作目录状态
const status = await GitManager.checkWorkspaceStatus(this.projectDir);
logs += `[${new Date().toISOString()}] 工作目录状态: ${status.status}\n`;
if (
status.status === WorkspaceDirStatus.NOT_CREATED ||
status.status === WorkspaceDirStatus.EMPTY
) {
// 目录不存在或为空,需要克隆
logs += `[${new Date().toISOString()}] 工作目录不存在或为空,开始克隆仓库\n`;
// 确保父目录存在
await GitManager.ensureDirectory(this.projectDir);
// 克隆仓库注意如果需要认证token 应该从环境变量或配置中获取)
await GitManager.cloneRepository(
project.repository,
this.projectDir,
branch,
// TODO: 添加 token 支持
);
logs += `[${new Date().toISOString()}] 仓库克隆成功\n`;
} else if (status.status === WorkspaceDirStatus.NO_GIT) {
// 目录存在但不是 Git 仓库
throw new Error(
`工作目录 ${this.projectDir} 已存在但不是 Git 仓库,请检查配置`,
);
} else if (status.status === WorkspaceDirStatus.READY) {
// 已存在 Git 仓库,更新代码
logs += `[${new Date().toISOString()}] 工作目录已存在 Git 仓库,开始更新代码\n`;
await GitManager.updateRepository(this.projectDir, branch);
logs += `[${new Date().toISOString()}] 代码更新成功\n`;
}
return logs;
} catch (error) {
const errorLog = `[${new Date().toISOString()}] 准备工作目录失败: ${(error as Error).message}\n`;
logs += errorLog;
log.error(
this.TAG,
'Failed to prepare workspace: %s',
(error as Error).message,
);
throw new Error(`准备工作目录失败: ${(error as Error).message}`);
}
}
/**
* 准备环境变量
* @param pipeline 流水线信息
* @param deployment 部署信息
*/
private prepareEnvironmentVariables(
pipeline: any,
deployment: any,
): Record<string, string> {
const envVars: Record<string, string> = {};
// 项目相关信息
if (pipeline.Project) {
envVars.REPOSITORY_URL = pipeline.Project.repository || '';
envVars.PROJECT_NAME = pipeline.Project.name || '';
}
// 部署相关信息
envVars.BRANCH_NAME = deployment.branch || '';
envVars.COMMIT_HASH = deployment.commitHash || '';
// 稀疏检出路径(如果有配置的话)
envVars.SPARSE_CHECKOUT_PATHS = deployment.sparseCheckoutPaths || '';
// 工作空间路径(使用配置的项目目录)
envVars.WORKSPACE = this.projectDir;
envVars.PROJECT_DIR = this.projectDir;
return envVars;
}
/**
* 为日志添加时间戳前缀
* @param message 日志消息
* @param isError 是否为错误日志
* @returns 带时间戳的日志消息
*/
private addTimestamp(message: string, isError = false): string {
const timestamp = new Date().toISOString();
if (isError) {
return `[${timestamp}] [ERROR] ${message}`;
}
return `[${timestamp}] ${message}`;
}
/**
* 为多行日志添加时间戳前缀
* @param content 多行日志内容
* @param isError 是否为错误日志
* @returns 带时间戳的多行日志消息
*/
private addTimestampToLines(content: string, isError = false): string {
if (!content) return '';
return (
content
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => this.addTimestamp(line, isError))
.join('\n') + '\n'
);
}
/**
* 执行单个步骤
* @param step 步骤对象
* @param envVars 环境变量
*/
private async executeStep(
step: Step,
envVars: Record<string, string>,
): Promise<string> {
let logs = '';
try {
// 添加步骤开始执行的时间戳
logs += this.addTimestamp(`执行脚本: ${step.script}`) + '\n';
// 使用zx执行脚本设置项目目录为工作目录和环境变量
const script = step.script;
// 通过bash -c执行脚本确保环境变量能被正确解析
const result = await $({
cwd: this.projectDir,
env: { ...process.env, ...envVars },
})`bash -c ${script}`;
if (result.stdout) {
// 为stdout中的每一行添加时间戳
logs += this.addTimestampToLines(result.stdout);
}
if (result.stderr) {
// 为stderr中的每一行添加时间戳和错误标记
logs += this.addTimestampToLines(result.stderr, true);
}
logs += this.addTimestamp(`步骤执行完成`) + '\n';
} catch (error) {
const errorMsg = `Error executing step "${step.name}": ${(error as Error).message}`;
logs += this.addTimestamp(errorMsg, true) + '\n';
log.error(this.TAG, errorMsg);
throw error;
}
return logs;
}
}

View File

@@ -4,7 +4,10 @@
"@tsconfig/node-ts/tsconfig.json" "@tsconfig/node-ts/tsconfig.json"
], ],
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "module": "ESNext",
"useDefineForClassFields": false "moduleResolution": "node",
"target": "ES2023",
"strict": true,
"esModuleInterop": true
} }
} }

View File

@@ -11,21 +11,22 @@
"preview": "rsbuild preview" "preview": "rsbuild preview"
}, },
"dependencies": { "dependencies": {
"@arco-design/web-react": "^2.66.4", "@arco-design/web-react": "^2.66.8",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"axios": "^1.11.0", "axios": "^1.11.0",
"react": "^18.2.0", "dayjs": "^1.11.19",
"react-dom": "^18.2.0", "react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.8.0", "react-router": "^7.8.0",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@arco-plugins/unplugin-react": "2.0.0-beta.5", "@arco-plugins/unplugin-react": "2.0.0-beta.5",
"@rsbuild/core": "^1.4.13", "@rsbuild/core": "^1.6.7",
"@rsbuild/plugin-less": "^1.4.0", "@rsbuild/plugin-less": "^1.5.0",
"@rsbuild/plugin-react": "^1.3.4", "@rsbuild/plugin-react": "^1.4.2",
"@rsbuild/plugin-svgr": "^1.2.2", "@rsbuild/plugin-svgr": "^1.2.2",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/react": "^18.3.24", "@types/react": "^18.3.24",

View File

@@ -1,7 +1,7 @@
import { ArcoDesignPlugin } from '@arco-plugins/unplugin-react'; import { ArcoDesignPlugin } from '@arco-plugins/unplugin-react';
import { defineConfig } from '@rsbuild/core'; import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginLess } from '@rsbuild/plugin-less'; import { pluginLess } from '@rsbuild/plugin-less';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginSvgr } from '@rsbuild/plugin-svgr'; import { pluginSvgr } from '@rsbuild/plugin-svgr';
export default defineConfig({ export default defineConfig({

View File

@@ -1,10 +1,18 @@
import React, { useEffect } from 'react'; import type React from 'react';
import { useEffect, useCallback } from 'react';
export function useAsyncEffect( export function useAsyncEffect(
effect: () => Promise<void>, effect: () => Promise<void | (() => void)>,
deps: React.DependencyList, deps: React.DependencyList,
) { ) {
const callback = useCallback(effect, [...deps]);
useEffect(() => { useEffect(() => {
effect(); const cleanupPromise = callback();
}, [...deps]); return () => {
if (cleanupPromise instanceof Promise) {
cleanupPromise.then(cleanup => cleanup && cleanup());
}
};
}, [callback]);
} }

View File

@@ -1,7 +1,8 @@
import ReactDOM from 'react-dom/client';
import App from '@pages/App'; import App from '@pages/App';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import { useGlobalStore } from './stores/global'; import { useGlobalStore } from './stores/global';
import '@arco-design/web-react/es/_util/react-19-adapter'
const rootEl = document.getElementById('root'); const rootEl = document.getElementById('root');

View File

@@ -1,9 +1,9 @@
import { Route, Routes, Navigate } from 'react-router'; import Env from '@pages/env';
import Home from '@pages/home'; import Home from '@pages/home';
import Login from '@pages/login'; import Login from '@pages/login';
import ProjectList from '@pages/project/list';
import ProjectDetail from '@pages/project/detail'; import ProjectDetail from '@pages/project/detail';
import Env from '@pages/env'; import ProjectList from '@pages/project/list';
import { Navigate, Route, Routes } from 'react-router';
import '@styles/index.css'; import '@styles/index.css';
const App = () => { const App = () => {

View File

@@ -1,12 +1,5 @@
import { useState } from "react";
function Env() { function Env() {
const [env, setEnv] = useState([]); return <div>env page</div>;
return (
<div>
env page
</div>
)
} }
export default Env; export default Env;

View File

@@ -6,11 +6,11 @@ import {
IconMenuUnfold, IconMenuUnfold,
IconRobot, IconRobot,
} from '@arco-design/web-react/icon'; } from '@arco-design/web-react/icon';
import { useState } from 'react';
import Logo from '@assets/images/logo.svg?react'; import Logo from '@assets/images/logo.svg?react';
import { loginService } from '@pages/login/service';
import { useState } from 'react';
import { Link, Outlet } from 'react-router'; import { Link, Outlet } from 'react-router';
import { useGlobalStore } from '../../stores/global'; import { useGlobalStore } from '../../stores/global';
import { loginService } from '@pages/login/service';
function Home() { function Home() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);

View File

@@ -1,11 +1,11 @@
import { Button } from '@arco-design/web-react'; import { Button } from '@arco-design/web-react';
import Gitea from '@assets/images/gitea.svg?react'; import Gitea from '@assets/images/gitea.svg?react';
import { loginService } from './service';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
import { loginService } from './service';
function Login() { function Login() {
const [ searchParams ] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const authCode = searchParams.get('code'); const authCode = searchParams.get('code');

View File

@@ -1,8 +1,8 @@
import { net } from '@shared';
import type { AuthURLResponse, AuthLoginResponse } from './types';
import type { NavigateFunction } from 'react-router';
import { Message, Notification } from '@arco-design/web-react'; import { Message, Notification } from '@arco-design/web-react';
import { net } from '@shared';
import type { NavigateFunction } from 'react-router';
import { useGlobalStore } from '../../stores/global'; import { useGlobalStore } from '../../stores/global';
import type { AuthLoginResponse, AuthURLResponse } from './types';
class LoginService { class LoginService {
async getAuthUrl() { async getAuthUrl() {

View File

@@ -0,0 +1,256 @@
import {
Button,
Form,
Input,
Message,
Modal,
Select,
} from '@arco-design/web-react';
import { formatDateTime } from '../../../../utils/time';
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,
sparseCheckoutPaths: values.sparseCheckoutPaths,
});
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">
{formatDateTime(commit.commit.author.date)}
</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>
<Form.Item
label="稀疏检出路径用于monorepo项目每行一个路径"
field="sparseCheckoutPaths"
tooltip="在monorepo项目中指定需要检出的目录路径每行一个路径。留空则检出整个仓库。"
>
<Input.TextArea
placeholder={`例如:\n/packages/frontend\n/packages/backend`}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
</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,9 @@
import { List, Tag, Space } from '@arco-design/web-react'; import { List, Space, Tag } from '@arco-design/web-react';
import { formatDateTime } from '../../../../utils/time';
// 部署记录类型定义 import type { Deployment } from '../../types';
interface DeployRecord {
id: number;
branch: string;
env: string;
commit: string;
status: 'success' | 'running' | 'failed' | 'pending';
createdAt: string;
}
interface DeployRecordItemProps { interface DeployRecordItemProps {
item: DeployRecord; item: Deployment;
isSelected: boolean; isSelected: boolean;
onSelect: (id: number) => void; onSelect: (id: number) => void;
} }
@@ -22,11 +14,8 @@ function DeployRecordItem({
onSelect, onSelect,
}: DeployRecordItemProps) { }: DeployRecordItemProps) {
// 状态标签渲染函数 // 状态标签渲染函数
const getStatusTag = (status: DeployRecord['status']) => { const getStatusTag = (status: Deployment['status']) => {
const statusMap: Record< const statusMap: Record<string, { color: string; text: string }> = {
DeployRecord['status'],
{ color: string; text: string }
> = {
success: { color: 'green', text: '成功' }, success: { color: 'green', text: '成功' },
running: { color: 'blue', text: '运行中' }, running: { color: 'blue', text: '运行中' },
failed: { color: 'red', text: '失败' }, failed: { color: 'red', text: '失败' },
@@ -46,6 +35,7 @@ function DeployRecordItem({
const config = envMap[env] || { color: 'gray', text: env }; const config = envMap[env] || { color: 'gray', text: env };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
}; };
return ( return (
<List.Item <List.Item
key={item.id} key={item.id}
@@ -67,7 +57,7 @@ function DeployRecordItem({
#{item.id} #{item.id}
</span> </span>
<span className="text-gray-600 text-sm font-mono bg-gray-100 px-2 py-1 rounded"> <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> </span>
</div> </div>
} }
@@ -76,12 +66,10 @@ function DeployRecordItem({
<Space size="medium" wrap> <Space size="medium" wrap>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
:{' '} :{' '}
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">{item.branch}</span>
{item.branch}
</span>
</span> </span>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
: {getEnvTag(item.env)} : {getEnvTag(item.env || 'unknown')}
</span> </span>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
: {getStatusTag(item.status)} : {getStatusTag(item.status)}
@@ -89,7 +77,7 @@ function DeployRecordItem({
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
:{' '} :{' '}
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{item.createdAt} {formatDateTime(item.createdAt)}
</span> </span>
</span> </span>
</Space> </Space>

View File

@@ -1,23 +1,24 @@
import { Typography, Tag, Switch, Button } from '@arco-design/web-react'; import { Button, Switch, Tag, Typography } from '@arco-design/web-react';
import { IconDragArrow, IconEdit, IconDelete } from '@arco-design/web-react/icon'; import {
IconDelete,
IconDragArrow,
IconEdit,
} from '@arco-design/web-react/icon';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import type { Step } from '../../types';
// 流水线步骤类型定义 interface StepWithEnabled extends Step {
interface PipelineStep {
id: string;
name: string;
script: string;
enabled: boolean; enabled: boolean;
} }
interface PipelineStepItemProps { interface PipelineStepItemProps {
step: PipelineStep; step: StepWithEnabled;
index: number; index: number;
pipelineId: string; pipelineId: number;
onToggle: (pipelineId: string, stepId: string, enabled: boolean) => void; onToggle: (pipelineId: number, stepId: number, enabled: boolean) => void;
onEdit: (pipelineId: string, step: PipelineStep) => void; onEdit: (pipelineId: number, step: StepWithEnabled) => void;
onDelete: (pipelineId: string, stepId: string) => void; onDelete: (pipelineId: number, stepId: number) => void;
} }
function PipelineStepItem({ function PipelineStepItem({
@@ -79,6 +80,9 @@ function PipelineStepItem({
</Tag> </Tag>
)} )}
</div> </div>
{step.description && (
<div className="text-gray-600 text-sm mb-2">{step.description}</div>
)}
<div className="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm"> <div className="bg-gray-900 text-green-400 p-3 rounded font-mono text-sm">
<pre className="whitespace-pre-wrap break-words">{step.script}</pre> <pre className="whitespace-pre-wrap break-words">{step.script}</pre>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,226 @@
import { net, type APIResponse } from '@shared'; import { type APIResponse, net } from '@shared';
import type { Project } from '../types'; import type { Branch, Commit, Deployment, Pipeline, Project, Step, CreateDeploymentRequest } from '../types';
class DetailService { class DetailService {
async getProject(id: string) { async getProject(id: string) {
const { code, data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<APIResponse<Project>>({
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
}); });
return data; return data;
} }
// 获取项目的所有流水线
async getPipelines(projectId: number) {
const { data } = await net.request<APIResponse<Pipeline[]>>({
url: `/api/pipelines?projectId=${projectId}`,
});
return data;
}
// 获取可用的流水线模板
async getPipelineTemplates() {
const { data } = await net.request<APIResponse<{id: number, name: string, description: string}[]>>({
url: '/api/pipelines/templates',
});
return data;
}
// 获取项目的部署记录
async getDeployments(projectId: number) {
const { data } = await net.request<any>({
url: `/api/deployments?projectId=${projectId}`,
});
return data.data;
}
// 创建流水线
async createPipeline(
pipeline: Omit<
Pipeline,
| 'id'
| 'createdAt'
| 'updatedAt'
| 'createdBy'
| 'updatedBy'
| 'valid'
| 'steps'
>,
) {
const { data } = await net.request<APIResponse<Pipeline>>({
url: '/api/pipelines',
method: 'POST',
data: pipeline,
});
return data;
}
// 基于模板创建流水线
async createPipelineFromTemplate(
templateId: number,
projectId: number,
name: string,
description?: string
) {
const { data } = await net.request<APIResponse<Pipeline>>({
url: '/api/pipelines/from-template',
method: 'POST',
data: {
templateId,
projectId,
name,
description
},
});
return data;
}
// 更新流水线
async updatePipeline(
id: number,
pipeline: Partial<
Omit<
Pipeline,
| 'id'
| 'createdAt'
| 'updatedAt'
| 'createdBy'
| 'updatedBy'
| 'valid'
| 'steps'
>
>,
) {
const { data } = await net.request<APIResponse<Pipeline>>({
url: `/api/pipelines/${id}`,
method: 'PUT',
data: pipeline,
});
return data;
}
// 删除流水线
async deletePipeline(id: number) {
const { data } = await net.request<APIResponse<null>>({
url: `/api/pipelines/${id}`,
method: 'DELETE',
});
return data;
}
// 获取流水线的所有步骤
async getSteps(pipelineId: number) {
const { data } = await net.request<APIResponse<Step[]>>({
url: `/api/steps?pipelineId=${pipelineId}`,
});
return data;
}
// 创建步骤
async createStep(
step: Omit<
Step,
'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid'
>,
) {
const { data } = await net.request<APIResponse<Step>>({
url: '/api/steps',
method: 'POST',
data: step,
});
return data;
}
// 更新步骤
async updateStep(
id: number,
step: Partial<
Omit<
Step,
'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid'
>
>,
) {
const { data } = await net.request<APIResponse<Step>>({
url: `/api/steps/${id}`,
method: 'PUT',
data: step,
});
return data;
}
// 删除步骤
async deleteStep(id: number) {
// DELETE请求返回204状态码通过拦截器处理为成功响应
const { data } = await net.request<APIResponse<null>>({
url: `/api/steps/${id}`,
method: 'DELETE',
});
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: CreateDeploymentRequest) {
const { data } = await net.request<APIResponse<Deployment>>({
url: '/api/deployments',
method: 'POST',
data: deployment,
});
return data;
}
// 重新执行部署
async retryDeployment(deploymentId: number) {
const { data } = await net.request<APIResponse<Deployment>>({
url: `/api/deployments/${deploymentId}/retry`,
method: 'POST',
});
return data;
}
// 获取项目详情(包含工作目录状态)
async getProjectDetail(id: number) {
const { data } = await net.request<APIResponse<Project>>({
url: `/api/projects/${id}`,
});
return data;
}
// 更新项目
async updateProject(
id: number,
project: Partial<{ name: string; description: string; repository: string }>,
) {
const { data } = await net.request<APIResponse<Project>>({
url: `/api/projects/${id}`,
method: 'PUT',
data: project,
});
return data;
}
// 删除项目
async deleteProject(id: number) {
await net.request({
url: `/api/projects/${id}`,
method: 'DELETE',
});
}
} }
export const detailService = new DetailService(); export const detailService = new DetailService();

View File

@@ -1,7 +1,7 @@
import { Modal, Form, Input, Button, Message } from '@arco-design/web-react'; import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
import React, { useState } from 'react'; import { useState } from 'react';
import type { Project } from '../types';
import { projectService } from '../service'; import { projectService } from '../service';
import type { Project } from '../../types';
interface CreateProjectModalProps { interface CreateProjectModalProps {
visible: boolean; visible: boolean;
@@ -9,7 +9,11 @@ interface CreateProjectModalProps {
onSuccess: (newProject: Project) => void; onSuccess: (newProject: Project) => void;
} }
function CreateProjectModal({ visible, onCancel, onSuccess }: CreateProjectModalProps) { function CreateProjectModal({
visible,
onCancel,
onSuccess,
}: CreateProjectModalProps) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -46,17 +50,18 @@ function CreateProjectModal({ visible, onCancel, onSuccess }: CreateProjectModal
<Button key="cancel" onClick={handleCancel}> <Button key="cancel" onClick={handleCancel}>
</Button>, </Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleSubmit}> <Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
>
</Button>, </Button>,
]} ]}
style={{ width: 500 }} style={{ width: 500 }}
> >
<Form <Form form={form} layout="vertical" autoComplete="off">
form={form}
layout="vertical"
autoComplete="off"
>
<Form.Item <Form.Item
label="项目名称" label="项目名称"
field="name" field="name"
@@ -71,9 +76,7 @@ function CreateProjectModal({ visible, onCancel, onSuccess }: CreateProjectModal
<Form.Item <Form.Item
label="项目描述" label="项目描述"
field="description" field="description"
rules={[ rules={[{ maxLength: 200, message: '项目描述不能超过200个字符' }]}
{ maxLength: 200, message: '项目描述不能超过200个字符' },
]}
> >
<Input.TextArea <Input.TextArea
placeholder="请输入项目描述(可选)" placeholder="请输入项目描述(可选)"
@@ -94,6 +97,33 @@ function CreateProjectModal({ visible, onCancel, onSuccess }: CreateProjectModal
> >
<Input placeholder="请输入仓库地址,如: https://github.com/user/repo" /> <Input placeholder="请输入仓库地址,如: https://github.com/user/repo" />
</Form.Item> </Form.Item>
<Form.Item
label="工作目录路径"
field="projectDir"
rules={[
{ required: true, message: '请输入工作目录路径' },
{
validator: (value, cb) => {
if (!value) {
return cb('工作目录路径不能为空');
}
if (!value.startsWith('/')) {
return cb('工作目录路径必须是绝对路径(以 / 开头)');
}
if (value.includes('..') || value.includes('~')) {
return cb('不能包含路径遍历字符(.. 或 ~');
}
if (/[<>:"|?*\x00-\x1f]/.test(value)) {
return cb('路径包含非法字符');
}
cb();
},
},
]}
>
<Input placeholder="请输入绝对路径,如: /data/projects/my-app" />
</Form.Item>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -1,7 +1,7 @@
import { Modal, Form, Input, Button, Message } from '@arco-design/web-react'; import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { Project } from '../types';
import { projectService } from '../service'; import { projectService } from '../service';
import type { Project } from '../types';
interface EditProjectModalProps { interface EditProjectModalProps {
visible: boolean; visible: boolean;
@@ -10,7 +10,12 @@ interface EditProjectModalProps {
onSuccess: (updatedProject: Project) => void; onSuccess: (updatedProject: Project) => void;
} }
function EditProjectModal({ visible, project, onCancel, onSuccess }: EditProjectModalProps) { function EditProjectModal({
visible,
project,
onCancel,
onSuccess,
}: EditProjectModalProps) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -59,17 +64,18 @@ function EditProjectModal({ visible, project, onCancel, onSuccess }: EditProject
<Button key="cancel" onClick={handleCancel}> <Button key="cancel" onClick={handleCancel}>
</Button>, </Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleSubmit}> <Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
>
</Button>, </Button>,
]} ]}
style={{ width: 500 }} style={{ width: 500 }}
> >
<Form <Form form={form} layout="vertical" autoComplete="off">
form={form}
layout="vertical"
autoComplete="off"
>
<Form.Item <Form.Item
label="项目名称" label="项目名称"
field="name" field="name"
@@ -84,9 +90,7 @@ function EditProjectModal({ visible, project, onCancel, onSuccess }: EditProject
<Form.Item <Form.Item
label="项目描述" label="项目描述"
field="description" field="description"
rules={[ rules={[{ maxLength: 200, message: '项目描述不能超过200个字符' }]}
{ maxLength: 200, message: '项目描述不能超过200个字符' },
]}
> >
<Input.TextArea <Input.TextArea
placeholder="请输入项目描述" placeholder="请输入项目描述"

View File

@@ -1,53 +1,28 @@
import { import {
Card,
Tag,
Avatar, Avatar,
Card,
Space, Space,
Tag,
Typography, Typography,
Button,
Tooltip,
Dropdown,
Menu,
Modal,
} from '@arco-design/web-react'; } from '@arco-design/web-react';
import { import {
IconBranch, IconBranch,
IconCalendar, IconCalendar,
IconCloud, IconCloud,
IconEdit,
IconMore,
IconDelete,
} from '@arco-design/web-react/icon'; } from '@arco-design/web-react/icon';
import type { Project } from '../../types';
import IconGitea from '@assets/images/gitea.svg?react'; import IconGitea from '@assets/images/gitea.svg?react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import type { Project } from '../../types';
const { Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;
interface ProjectCardProps { interface ProjectCardProps {
project: Project; project: Project;
onEdit?: (project: Project) => void;
onDelete?: (project: Project) => void;
} }
function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) { function ProjectCard({ project }: ProjectCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
// 处理删除操作
const handleDelete = () => {
Modal.confirm({
title: '确认删除项目',
content: `确定要删除项目 "${project.name}" 吗?此操作不可恢复。`,
okText: '删除',
cancelText: '取消',
okButtonProps: {
status: 'danger',
},
onOk: () => {
onDelete?.(project);
},
});
};
// 获取环境信息 // 获取环境信息
const environments = [ const environments = [
{ name: 'staging', color: 'orange', icon: '🚧' }, { name: 'staging', color: 'orange', icon: '🚧' },
@@ -109,37 +84,9 @@ function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) {
</Text> </Text>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <Tag color="blue" size="small" className="font-medium">
<Tag color="blue" size="small" className="font-medium">
</Tag>
</Tag>
<Dropdown
droplist={
<Menu>
<Menu.Item key="edit" onClick={() => onEdit?.(project)}>
<IconEdit className="mr-2" />
</Menu.Item>
<Menu.Item
key="delete"
onClick={() => handleDelete()}
className="text-red-500"
>
<IconDelete className="mr-2" />
</Menu.Item>
</Menu>
}
position="br"
>
<Button
type="text"
size="small"
icon={<IconMore />}
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50 transition-all duration-200 p-1 rounded-md"
/>
</Dropdown>
</div>
</div> </div>
{/* 项目描述 */} {/* 项目描述 */}

View File

@@ -1,19 +1,16 @@
import { Grid, Typography, Button, Message } from '@arco-design/web-react'; import { Button, Grid, Message, Typography } from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon'; import { IconPlus } from '@arco-design/web-react/icon';
import { useState } from 'react'; import { useState } from 'react';
import type { Project } from '../types';
import { useAsyncEffect } from '../../../hooks/useAsyncEffect'; import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
import { projectService } from './service'; import type { Project } from '../types';
import ProjectCard from './components/ProjectCard';
import EditProjectModal from './components/EditProjectModal';
import CreateProjectModal from './components/CreateProjectModal'; import CreateProjectModal from './components/CreateProjectModal';
import ProjectCard from './components/ProjectCard';
import { projectService } from './service';
const { Text } = Typography; const { Text } = Typography;
function ProjectPage() { function ProjectPage() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [createModalVisible, setCreateModalVisible] = useState(false); const [createModalVisible, setCreateModalVisible] = useState(false);
useAsyncEffect(async () => { useAsyncEffect(async () => {
@@ -21,45 +18,18 @@ function ProjectPage() {
setProjects(response.data); setProjects(response.data);
}, []); }, []);
const handleEditProject = (project: Project) => {
setEditingProject(project);
setEditModalVisible(true);
};
const handleEditSuccess = (updatedProject: Project) => {
setProjects(prev =>
prev.map(p => p.id === updatedProject.id ? updatedProject : p)
);
};
const handleEditCancel = () => {
setEditModalVisible(false);
setEditingProject(null);
};
const handleCreateProject = () => { const handleCreateProject = () => {
setCreateModalVisible(true); setCreateModalVisible(true);
}; };
const handleCreateSuccess = (newProject: Project) => { const handleCreateSuccess = (newProject: Project) => {
setProjects(prev => [newProject, ...prev]); setProjects((prev) => [newProject, ...prev]);
}; };
const handleCreateCancel = () => { const handleCreateCancel = () => {
setCreateModalVisible(false); setCreateModalVisible(false);
}; };
const handleDeleteProject = async (project: Project) => {
try {
await projectService.delete(project.id);
setProjects(prev => prev.filter(p => p.id !== project.id));
Message.success('项目删除成功');
} catch (error) {
console.error('删除项目失败:', error);
Message.error('删除项目失败,请稍后重试');
}
};
return ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
@@ -82,22 +52,11 @@ function ProjectPage() {
<Grid.Row gutter={[16, 16]}> <Grid.Row gutter={[16, 16]}>
{projects.map((project) => ( {projects.map((project) => (
<Grid.Col key={project.id} span={8}> <Grid.Col key={project.id} span={8}>
<ProjectCard <ProjectCard project={project} />
project={project}
onEdit={handleEditProject}
onDelete={handleDeleteProject}
/>
</Grid.Col> </Grid.Col>
))} ))}
</Grid.Row> </Grid.Row>
<EditProjectModal
visible={editModalVisible}
project={editingProject}
onCancel={handleEditCancel}
onSuccess={handleEditSuccess}
/>
<CreateProjectModal <CreateProjectModal
visible={createModalVisible} visible={createModalVisible}
onCancel={handleCreateCancel} onCancel={handleCreateCancel}

View File

@@ -1,7 +1,6 @@
import { net, type APIResponse } from '@shared'; import { type APIResponse, net } from '@shared';
import type { Project } from '../types'; import type { Project } from '../types';
class ProjectService { class ProjectService {
async list(params?: ProjectQueryParams) { async list(params?: ProjectQueryParams) {
const { data } = await net.request<APIResponse<ProjectListResponse>>({ const { data } = await net.request<APIResponse<ProjectListResponse>>({

View File

@@ -1,7 +1,33 @@
enum BuildStatus { enum BuildStatus {
Idle = "Pending", Idle = 'Pending',
Running = "Running", Running = 'Running',
Stopped = "Stopped", Stopped = 'Stopped',
}
// 工作目录状态枚举
export enum WorkspaceDirStatus {
NOT_CREATED = 'not_created',
EMPTY = 'empty',
NO_GIT = 'no_git',
READY = 'ready',
}
// Git 仓库信息
export interface GitInfo {
branch?: string;
lastCommit?: string;
lastCommitMessage?: string;
}
// 工作目录状态信息
export interface WorkspaceStatus {
status: WorkspaceDirStatus;
exists: boolean;
isEmpty?: boolean;
hasGit?: boolean;
size?: number;
gitInfo?: GitInfo;
error?: string;
} }
export interface Project { export interface Project {
@@ -9,10 +35,98 @@ export interface Project {
name: string; name: string;
description: string; description: string;
repository: string; repository: string;
projectDir: string; // 项目工作目录路径(必填)
valid: number; valid: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
createdBy: string; createdBy: string;
updatedBy: string; updatedBy: string;
status: BuildStatus; status: BuildStatus;
workspaceStatus?: WorkspaceStatus; // 工作目录状态信息
}
// 流水线步骤类型定义
export interface Step {
id: number;
name: string;
description?: string;
order: number;
script: string; // 执行的脚本命令
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
pipelineId: number;
}
// 流水线类型定义
export interface Pipeline {
id: number;
name: string;
description?: string;
valid: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
projectId?: number;
steps?: Step[];
}
export interface Deployment {
id: number;
branch: string;
env?: string;
status: string;
commitHash?: string;
commitMessage?: string;
buildLog?: string;
sparseCheckoutPaths?: string; // 稀疏检出路径用于monorepo项目
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;
};
};
}
// 创建部署请求的类型定义
export interface CreateDeploymentRequest {
projectId: number;
pipelineId: number;
branch: string;
commitHash: string;
commitMessage: string;
env?: string;
sparseCheckoutPaths?: string; // 稀疏检出路径用于monorepo项目
} }

View File

@@ -1,4 +1,4 @@
import axios, { Axios, type AxiosRequestConfig } from 'axios'; import axios, { type Axios, type AxiosRequestConfig } from 'axios';
class Net { class Net {
private readonly instance: Axios; private readonly instance: Axios;
@@ -18,7 +18,17 @@ class Net {
return response; return response;
}, },
(error) => { (error) => {
console.log('error', error) console.log('error', error);
// 对于DELETE请求返回204状态码的情况视为成功
if (error.response && error.response.status === 204 && error.config.method === 'delete') {
// 创建一个模拟的成功响应
return Promise.resolve({
...error.response,
data: error.response.data || null,
status: 200, // 将204转换为200避免被当作错误处理
});
}
if (error.status === 401 && error.config.url !== '/api/auth/info') { if (error.status === 401 && error.config.url !== '/api/auth/info') {
window.location.href = '/login'; window.location.href = '/login';
return; return;
@@ -29,8 +39,16 @@ class Net {
} }
async request<T>(config: AxiosRequestConfig): Promise<T> { async request<T>(config: AxiosRequestConfig): Promise<T> {
const { data } = await this.instance.request<T>(config); try {
return data; const response = await this.instance.request<T>(config);
if (!response || !response.data) {
throw new Error('Invalid response');
}
return response.data;
} catch (error) {
console.error('Request failed:', error);
throw error;
}
} }
} }

View File

@@ -1,4 +1,4 @@
import { net, type APIResponse } from '@shared'; import { type APIResponse, net } from '@shared';
import { create } from 'zustand'; import { create } from 'zustand';
interface User { interface User {

View File

@@ -1 +1 @@
@import 'tailwindcss'; @import "tailwindcss";

View File

@@ -0,0 +1,31 @@
import dayjs from 'dayjs';
/**
* 格式化时间为 YYYY-MM-DD HH:mm:ss
* @param date 时间字符串或 Date 对象
* @returns 格式化后的时间字符串
*/
export function formatDateTime(date: string | Date | undefined | null): string {
if (!date) return '-';
return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
}
/**
* 格式化时间为 YYYY-MM-DD
* @param date 时间字符串或 Date 对象
* @returns 格式化后的日期字符串
*/
export function formatDate(date: string | Date | undefined | null): string {
if (!date) return '-';
return dayjs(date).format('YYYY-MM-DD');
}
/**
* 格式化时间为 HH:mm:ss
* @param date 时间字符串或 Date 对象
* @returns 格式化后的时间字符串
*/
export function formatTime(date: string | Date | undefined | null): string {
if (!date) return '-';
return dayjs(date).format('HH:mm:ss');
}

View File

@@ -20,7 +20,7 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"paths": { "paths": {
"@pages/*": ["./src/pages/*"], "@pages/*": ["./src/pages/*"],
"@styles/*": ["./src/styles/*"], "@styles/*": ["./src/styles/*"],
"@assets/*": ["./src/assets/*"], "@assets/*": ["./src/assets/*"],

2075
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff