Compare commits
17 Commits
ad91a2f54d
...
001-projec
| Author | SHA1 | Date | |
|---|---|---|---|
| b5c550f5c5 | |||
| 9897bd04c2 | |||
| 73240d94b1 | |||
| 378070179f | |||
| 02b7c3edb2 | |||
| f8697b87e1 | |||
| fd0cf782c4 | |||
| ef4fce6d42 | |||
| f0e1a649ee | |||
| cd99485c9a | |||
| 5a25f350c7 | |||
| 9b54d18ef3 | |||
| ef473d6084 | |||
| d178df54da | |||
| 63c1e4df63 | |||
| 47f36cd625 | |||
| 2edf8753a7 |
5
apps/server/.gitignore
vendored
Normal file
5
apps/server/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
239
apps/server/README-RESTful-API.md
Normal file
239
apps/server/README-RESTful-API.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# RESTful API 设计规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本项目采用 RESTful API 设计规范,提供统一、直观的 HTTP 接口设计。
|
||||||
|
|
||||||
|
## API 设计原则
|
||||||
|
|
||||||
|
### 1. 资源命名规范
|
||||||
|
- 使用名词复数形式作为资源名称
|
||||||
|
- 示例:`/api/projects`、`/api/users`
|
||||||
|
|
||||||
|
### 2. HTTP 方法语义
|
||||||
|
- `GET`: 获取资源
|
||||||
|
- `POST`: 创建资源
|
||||||
|
- `PUT`: 更新整个资源
|
||||||
|
- `PATCH`: 部分更新资源
|
||||||
|
- `DELETE`: 删除资源
|
||||||
|
|
||||||
|
### 3. 状态码规范
|
||||||
|
- `200 OK`: 成功获取或更新资源
|
||||||
|
- `201 Created`: 成功创建资源
|
||||||
|
- `204 No Content`: 成功删除资源
|
||||||
|
- `400 Bad Request`: 请求参数错误
|
||||||
|
- `404 Not Found`: 资源不存在
|
||||||
|
- `500 Internal Server Error`: 服务器内部错误
|
||||||
|
|
||||||
|
## 项目 API 接口
|
||||||
|
|
||||||
|
### 项目资源 (Projects)
|
||||||
|
|
||||||
|
#### 1. 获取项目列表
|
||||||
|
```
|
||||||
|
GET /api/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
- `page` (可选): 页码,默认为 1
|
||||||
|
- `limit` (可选): 每页数量,默认为 10,最大 100
|
||||||
|
- `name` (可选): 项目名称搜索
|
||||||
|
|
||||||
|
**响应格式:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "获取列表成功,共N条记录",
|
||||||
|
"data": {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "项目名称",
|
||||||
|
"description": "项目描述",
|
||||||
|
"repository": "https://github.com/user/repo",
|
||||||
|
"valid": 1,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "system",
|
||||||
|
"updatedBy": "system"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"limit": 10,
|
||||||
|
"total": 100,
|
||||||
|
"totalPages": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timestamp": 1700000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 获取单个项目
|
||||||
|
```
|
||||||
|
GET /api/projects/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数:**
|
||||||
|
- `id`: 项目ID(整数)
|
||||||
|
|
||||||
|
**响应格式:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "获取数据成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "项目名称",
|
||||||
|
"description": "项目描述",
|
||||||
|
"repository": "https://github.com/user/repo",
|
||||||
|
"valid": 1,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "system",
|
||||||
|
"updatedBy": "system"
|
||||||
|
},
|
||||||
|
"timestamp": 1700000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 创建项目
|
||||||
|
```
|
||||||
|
POST /api/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "项目名称",
|
||||||
|
"description": "项目描述(可选)",
|
||||||
|
"repository": "https://github.com/user/repo"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证规则:**
|
||||||
|
- `name`: 必填,2-50个字符
|
||||||
|
- `description`: 可选,最多200个字符
|
||||||
|
- `repository`: 必填,有效的URL格式
|
||||||
|
|
||||||
|
**响应格式:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "获取数据成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "项目名称",
|
||||||
|
"description": "项目描述",
|
||||||
|
"repository": "https://github.com/user/repo",
|
||||||
|
"valid": 1,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "system",
|
||||||
|
"updatedBy": "system"
|
||||||
|
},
|
||||||
|
"timestamp": 1700000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 更新项目
|
||||||
|
```
|
||||||
|
PUT /api/projects/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数:**
|
||||||
|
- `id`: 项目ID(整数)
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "新项目名称(可选)",
|
||||||
|
"description": "新项目描述(可选)",
|
||||||
|
"repository": "https://github.com/user/newrepo(可选)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证规则:**
|
||||||
|
- `name`: 可选,2-50个字符
|
||||||
|
- `description`: 可选,最多200个字符
|
||||||
|
- `repository`: 可选,有效的URL格式
|
||||||
|
|
||||||
|
**响应格式:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "获取数据成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "新项目名称",
|
||||||
|
"description": "新项目描述",
|
||||||
|
"repository": "https://github.com/user/newrepo",
|
||||||
|
"valid": 1,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T12:00:00Z",
|
||||||
|
"createdBy": "system",
|
||||||
|
"updatedBy": "system"
|
||||||
|
},
|
||||||
|
"timestamp": 1700000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 删除项目
|
||||||
|
```
|
||||||
|
DELETE /api/projects/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数:**
|
||||||
|
- `id`: 项目ID(整数)
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
- HTTP 状态码: 204 No Content
|
||||||
|
- 响应体: 空
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 验证错误 (400 Bad Request)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1003,
|
||||||
|
"message": "项目名称至少2个字符",
|
||||||
|
"data": {
|
||||||
|
"field": "name",
|
||||||
|
"validationErrors": [
|
||||||
|
{
|
||||||
|
"field": "name",
|
||||||
|
"message": "项目名称至少2个字符",
|
||||||
|
"code": "too_small"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timestamp": 1700000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 资源不存在 (404 Not Found)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1002,
|
||||||
|
"message": "项目不存在",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": 1700000000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 统一响应格式
|
||||||
|
所有 API 都使用统一的响应格式,包含 `code`、`message`、`data`、`timestamp` 字段。
|
||||||
|
|
||||||
|
### 2. 参数验证
|
||||||
|
使用 Zod 进行严格的参数验证,确保数据的完整性和安全性。
|
||||||
|
|
||||||
|
### 3. 错误处理
|
||||||
|
全局异常处理中间件统一处理各种错误,提供一致的错误响应格式。
|
||||||
|
|
||||||
|
### 4. 分页支持
|
||||||
|
列表接口支持分页功能,返回分页信息方便前端处理。
|
||||||
|
|
||||||
|
### 5. 软删除
|
||||||
|
删除操作采用软删除方式,将 `valid` 字段设置为 0,保留数据历史。
|
||||||
231
apps/server/README-TC39-decorators.md
Normal file
231
apps/server/README-TC39-decorators.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# 路由装饰器使用指南(TC39 标准)
|
||||||
|
|
||||||
|
本项目使用符合 **TC39 Stage 3 提案**的标准装饰器语法,提供现代化的路由定义方式。
|
||||||
|
|
||||||
|
## TC39 装饰器优势
|
||||||
|
|
||||||
|
- ✅ **标准化**:符合 ECMAScript 官方标准提案
|
||||||
|
- ✅ **类型安全**:完整的 TypeScript 类型支持
|
||||||
|
- ✅ **性能优化**:无需 reflect-metadata 依赖
|
||||||
|
- ✅ **未来兼容**:随着标准发展自动获得浏览器支持
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 创建控制器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Context } from 'koa';
|
||||||
|
import { Controller, Get, Post, Put, Delete } from '../decorators/route.ts';
|
||||||
|
import { BusinessError } from '../middlewares/exception.ts';
|
||||||
|
|
||||||
|
@Controller('/api-prefix') // 控制器路由前缀
|
||||||
|
export class MyController {
|
||||||
|
|
||||||
|
@Get('/users')
|
||||||
|
async getUsers(ctx: Context) {
|
||||||
|
// 直接返回数据,自动包装成统一响应格式
|
||||||
|
return { users: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/users')
|
||||||
|
async createUser(ctx: Context) {
|
||||||
|
const userData = (ctx.request as any).body;
|
||||||
|
// 业务逻辑处理...
|
||||||
|
return { id: 1, ...userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('/users/:id')
|
||||||
|
async updateUser(ctx: Context) {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const userData = (ctx.request as any).body;
|
||||||
|
// 业务逻辑处理...
|
||||||
|
return { id, ...userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/users/:id')
|
||||||
|
async deleteUser(ctx: Context) {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
// 业务逻辑处理...
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 注册控制器
|
||||||
|
|
||||||
|
在 `middlewares/router.ts` 的 `registerDecoratorRoutes()` 方法中添加你的控制器:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.routeScanner.registerControllers([
|
||||||
|
ApplicationController,
|
||||||
|
UserController,
|
||||||
|
MyController // 添加你的控制器
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 可用装饰器
|
||||||
|
|
||||||
|
### HTTP方法装饰器(TC39 标准)
|
||||||
|
|
||||||
|
- `@Get(path)` - GET 请求
|
||||||
|
- `@Post(path)` - POST 请求
|
||||||
|
- `@Put(path)` - PUT 请求
|
||||||
|
- `@Delete(path)` - DELETE 请求
|
||||||
|
- `@Patch(path)` - PATCH 请求
|
||||||
|
|
||||||
|
### 控制器装饰器(TC39 标准)
|
||||||
|
|
||||||
|
- `@Controller(prefix)` - 控制器路由前缀
|
||||||
|
|
||||||
|
## TC39 装饰器特性
|
||||||
|
|
||||||
|
### 1. 标准语法
|
||||||
|
```typescript
|
||||||
|
// TC39 标准装饰器使用 addInitializer
|
||||||
|
@Get('/users')
|
||||||
|
async getUsers(ctx: Context) {
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 类型安全
|
||||||
|
```typescript
|
||||||
|
// 完整的 TypeScript 类型检查
|
||||||
|
@Controller('/api')
|
||||||
|
export class ApiController {
|
||||||
|
@Get('/health')
|
||||||
|
healthCheck(): { status: string } {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 无外部依赖
|
||||||
|
```typescript
|
||||||
|
// 不再需要 reflect-metadata
|
||||||
|
// 使用内置的 WeakMap 存储元数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置要求
|
||||||
|
|
||||||
|
### TypeScript 配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": false, // 关闭实验性装饰器
|
||||||
|
"emitDecoratorMetadata": false, // 关闭元数据发射
|
||||||
|
"target": "ES2022", // 目标 ES2022+
|
||||||
|
"useDefineForClassFields": false // 兼容装饰器行为
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
// 无需 reflect-metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 路径拼接规则
|
||||||
|
|
||||||
|
最终的API路径 = 全局前缀 + 控制器前缀 + 方法路径
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- 全局前缀:`/api`
|
||||||
|
- 控制器前缀:`/user`
|
||||||
|
- 方法路径:`/list`
|
||||||
|
- 最终路径:`/api/user/list`
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
控制器方法只需要返回数据,系统会自动包装成统一响应格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": { /* 控制器返回的数据 */ },
|
||||||
|
"timestamp": 1693478400000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 异常处理
|
||||||
|
|
||||||
|
可以抛出 `BusinessError` 来返回业务异常:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BusinessError } from '../middlewares/exception.ts';
|
||||||
|
|
||||||
|
@Get('/users/:id')
|
||||||
|
async getUser(ctx: Context) {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new BusinessError('用户ID不能为空', 1001, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常业务逻辑...
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 现有路由
|
||||||
|
|
||||||
|
项目中已注册的路由:
|
||||||
|
|
||||||
|
### ApplicationController
|
||||||
|
- `GET /api/application/list` - 获取应用列表
|
||||||
|
- `GET /api/application/detail/:id` - 获取应用详情
|
||||||
|
|
||||||
|
### UserController
|
||||||
|
- `GET /api/user/list` - 获取用户列表
|
||||||
|
- `GET /api/user/detail/:id` - 获取用户详情
|
||||||
|
- `POST /api/user` - 创建用户
|
||||||
|
- `PUT /api/user/:id` - 更新用户
|
||||||
|
- `DELETE /api/user/:id` - 删除用户
|
||||||
|
- `GET /api/user/search` - 搜索用户
|
||||||
|
|
||||||
|
## 与旧版本装饰器的区别
|
||||||
|
|
||||||
|
| 特性 | 实验性装饰器 | TC39 标准装饰器 |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| 标准化 | ❌ TypeScript 特有 | ✅ ECMAScript 标准 |
|
||||||
|
| 依赖 | ❌ 需要 reflect-metadata | ✅ 零依赖 |
|
||||||
|
| 性能 | ❌ 运行时反射 | ✅ 编译时优化 |
|
||||||
|
| 类型安全 | ⚠️ 部分支持 | ✅ 完整支持 |
|
||||||
|
| 未来兼容 | ❌ 可能被废弃 | ✅ 持续演进 |
|
||||||
|
|
||||||
|
## 迁移指南
|
||||||
|
|
||||||
|
从实验性装饰器迁移到 TC39 标准装饰器:
|
||||||
|
|
||||||
|
1. **更新 tsconfig.json**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experimentalDecorators": false,
|
||||||
|
"emitDecoratorMetadata": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **移除依赖**
|
||||||
|
```bash
|
||||||
|
pnpm remove reflect-metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **代码无需修改**
|
||||||
|
- 装饰器语法保持不变
|
||||||
|
- 控制器代码无需修改
|
||||||
|
- 自动兼容新标准
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 需要 TypeScript 5.0+ 支持
|
||||||
|
2. 需要 Node.js 16+ 运行环境
|
||||||
|
3. 控制器类需要导出并在路由中间件中注册
|
||||||
|
4. 控制器方法应该返回数据而不是直接操作 `ctx.body`
|
||||||
|
5. TC39 装饰器使用 `addInitializer` 进行初始化,性能更优
|
||||||
7
apps/server/README.md
Normal file
7
apps/server/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
## 表
|
||||||
|
- user
|
||||||
|
- project
|
||||||
|
- pipeline
|
||||||
|
- deployment
|
||||||
|
- runner
|
||||||
32
apps/server/app.ts
Normal file
32
apps/server/app.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Koa from 'koa';
|
||||||
|
import { initMiddlewares } from './middlewares/index.ts';
|
||||||
|
import { log } from './libs/logger.ts';
|
||||||
|
import { ExecutionQueue } from './libs/execution-queue.ts';
|
||||||
|
import { initializePipelineTemplates } from './libs/pipeline-template.ts';
|
||||||
|
|
||||||
|
// 初始化应用
|
||||||
|
async function initializeApp() {
|
||||||
|
// 初始化流水线模板
|
||||||
|
await initializePipelineTemplates();
|
||||||
|
|
||||||
|
// 初始化执行队列
|
||||||
|
const executionQueue = ExecutionQueue.getInstance();
|
||||||
|
await executionQueue.initialize();
|
||||||
|
|
||||||
|
const app = new Koa();
|
||||||
|
|
||||||
|
initMiddlewares(app);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
7
apps/server/controllers/auth/dto.ts
Normal file
7
apps/server/controllers/auth/dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
code: z.string().min(1, { message: 'Code不能为空' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
84
apps/server/controllers/auth/index.ts
Normal file
84
apps/server/controllers/auth/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Context } from 'koa';
|
||||||
|
import { Controller, Get, Post } from '../../decorators/route.ts';
|
||||||
|
import { prisma } from '../../libs/prisma.ts';
|
||||||
|
import { log } from '../../libs/logger.ts';
|
||||||
|
import { gitea } from '../../libs/gitea.ts';
|
||||||
|
import { loginSchema } from './dto.ts';
|
||||||
|
|
||||||
|
@Controller('/auth')
|
||||||
|
export class AuthController {
|
||||||
|
private readonly TAG = 'Auth';
|
||||||
|
|
||||||
|
@Get('/url')
|
||||||
|
async url() {
|
||||||
|
return {
|
||||||
|
url: `${process.env.GITEA_URL}/login/oauth/authorize?client_id=${process.env.GITEA_CLIENT_ID}&redirect_uri=${process.env.GITEA_REDIRECT_URI}&response_type=code&state=STATE`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/login')
|
||||||
|
async login(ctx: Context) {
|
||||||
|
if (ctx.session.user) {
|
||||||
|
return ctx.session.user;
|
||||||
|
}
|
||||||
|
const { code } = loginSchema.parse(ctx.request.body);
|
||||||
|
const { access_token, refresh_token, expires_in } =
|
||||||
|
await gitea.getToken(code);
|
||||||
|
const giteaAuth = {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_at: Date.now() + expires_in * 1000,
|
||||||
|
};
|
||||||
|
const giteaUser = await gitea.getUserInfo(access_token);
|
||||||
|
log.debug(this.TAG, 'gitea user: %o', giteaUser);
|
||||||
|
const exist = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
login: giteaUser.login,
|
||||||
|
email: giteaUser.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (exist == null) {
|
||||||
|
const createdUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: giteaUser.id,
|
||||||
|
login: giteaUser.login,
|
||||||
|
email: giteaUser.email,
|
||||||
|
username: giteaUser.username,
|
||||||
|
avatar_url: giteaUser.avatar_url,
|
||||||
|
active: giteaUser.active,
|
||||||
|
createdAt: giteaUser.created,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.debug(this.TAG, '新建用户成功 %o', createdUser);
|
||||||
|
ctx.session.user = createdUser;
|
||||||
|
} else {
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: exist.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
login: giteaUser.login,
|
||||||
|
email: giteaUser.email,
|
||||||
|
username: giteaUser.username,
|
||||||
|
avatar_url: giteaUser.avatar_url,
|
||||||
|
active: giteaUser.active,
|
||||||
|
createdAt: giteaUser.created,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.debug(this.TAG, '更新用户信息成功 %o', updatedUser);
|
||||||
|
ctx.session.user = updatedUser;
|
||||||
|
}
|
||||||
|
ctx.session.gitea = giteaAuth;
|
||||||
|
return ctx.session.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('logout')
|
||||||
|
async logout(ctx: Context) {
|
||||||
|
ctx.session.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('info')
|
||||||
|
async info(ctx: Context) {
|
||||||
|
return ctx.session?.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/server/controllers/deployment/dto.ts
Normal file
20
apps/server/controllers/deployment/dto.ts
Normal 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>;
|
||||||
119
apps/server/controllers/deployment/index.ts
Normal file
119
apps/server/controllers/deployment/index.ts
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/controllers/git/dto.ts
Normal file
13
apps/server/controllers/git/dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const getCommitsQuerySchema = z.object({
|
||||||
|
projectId: z.coerce.number().int().positive({ message: 'Project ID is required' }),
|
||||||
|
branch: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getBranchesQuerySchema = z.object({
|
||||||
|
projectId: z.coerce.number().int().positive({ message: 'Project ID is required' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GetCommitsQuery = z.infer<typeof getCommitsQuerySchema>;
|
||||||
|
export type GetBranchesQuery = z.infer<typeof getBranchesQuerySchema>;
|
||||||
113
apps/server/controllers/git/index.ts
Normal file
113
apps/server/controllers/git/index.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Context } from 'koa';
|
||||||
|
import { Controller, Get } from '../../decorators/route.ts';
|
||||||
|
import { prisma } from '../../libs/prisma.ts';
|
||||||
|
import { gitea } from '../../libs/gitea.ts';
|
||||||
|
import { BusinessError } from '../../middlewares/exception.ts';
|
||||||
|
import { getCommitsQuerySchema, getBranchesQuerySchema } from './dto.ts';
|
||||||
|
|
||||||
|
@Controller('/git')
|
||||||
|
export class GitController {
|
||||||
|
@Get('/commits')
|
||||||
|
async getCommits(ctx: Context) {
|
||||||
|
const { projectId, branch } = getCommitsQuerySchema.parse(ctx.query);
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
valid: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new BusinessError('Project not found', 1002, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse repository URL to get owner and repo
|
||||||
|
// Supports:
|
||||||
|
// https://gitea.com/owner/repo.git
|
||||||
|
// http://gitea.com/owner/repo
|
||||||
|
const { owner, repo } = this.parseRepoUrl(project.repository);
|
||||||
|
|
||||||
|
// Get access token from session
|
||||||
|
const accessToken = ctx.session?.gitea?.access_token;
|
||||||
|
console.log('Access token present:', !!accessToken);
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new BusinessError('Gitea access token not found. Please login again.', 1004, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commits = await gitea.getCommits(owner, repo, accessToken, branch);
|
||||||
|
return commits;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch commits:', error);
|
||||||
|
throw new BusinessError('Failed to fetch commits from Gitea', 1005, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/branches')
|
||||||
|
async getBranches(ctx: Context) {
|
||||||
|
const { projectId } = getBranchesQuerySchema.parse(ctx.query);
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
valid: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new BusinessError('Project not found', 1002, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { owner, repo } = this.parseRepoUrl(project.repository);
|
||||||
|
|
||||||
|
const accessToken = ctx.session?.gitea?.access_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new BusinessError('Gitea access token not found. Please login again.', 1004, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const branches = await gitea.getBranches(owner, repo, accessToken);
|
||||||
|
return branches;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch branches:', error);
|
||||||
|
throw new BusinessError('Failed to fetch branches from Gitea', 1006, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRepoUrl(url: string) {
|
||||||
|
let cleanUrl = url.trim();
|
||||||
|
if (cleanUrl.endsWith('/')) {
|
||||||
|
cleanUrl = cleanUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SCP-like syntax: git@host:owner/repo.git
|
||||||
|
if (!cleanUrl.includes('://') && cleanUrl.includes(':')) {
|
||||||
|
const scpMatch = cleanUrl.match(/:([^\/]+)\/([^\/]+?)(\.git)?$/);
|
||||||
|
if (scpMatch) {
|
||||||
|
return { owner: scpMatch[1], repo: scpMatch[2] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle HTTP/HTTPS/SSH URLs
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(cleanUrl);
|
||||||
|
const parts = urlObj.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const repo = parts.pop()!.replace(/\.git$/, '');
|
||||||
|
const owner = parts.pop()!;
|
||||||
|
return { owner, repo };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to simple regex
|
||||||
|
const match = cleanUrl.match(/([^\/]+)\/([^\/]+?)(\.git)?$/);
|
||||||
|
if (match) {
|
||||||
|
return { owner: match[1], repo: match[2] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessError('Invalid repository URL format', 1003, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/server/controllers/index.ts
Normal file
8
apps/server/controllers/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// 控制器统一导出
|
||||||
|
export { ProjectController } from './project/index.ts';
|
||||||
|
export { UserController } from './user/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';
|
||||||
40
apps/server/controllers/pipeline/dto.ts
Normal file
40
apps/server/controllers/pipeline/dto.ts
Normal 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>;
|
||||||
242
apps/server/controllers/pipeline/index.ts
Normal file
242
apps/server/controllers/pipeline/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/server/controllers/project/dto.ts
Normal file
60
apps/server/controllers/project/dto.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { projectDirSchema } from '../../libs/path-validator.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建项目验证架构
|
||||||
|
*/
|
||||||
|
export const createProjectSchema = z.object({
|
||||||
|
name: z.string({
|
||||||
|
message: '项目名称必须是字符串',
|
||||||
|
}).min(2, { message: '项目名称至少2个字符' }).max(50, { message: '项目名称不能超过50个字符' }),
|
||||||
|
|
||||||
|
description: z.string({
|
||||||
|
message: '项目描述必须是字符串',
|
||||||
|
}).max(200, { message: '项目描述不能超过200个字符' }).optional(),
|
||||||
|
|
||||||
|
repository: z.string({
|
||||||
|
message: '仓库地址必须是字符串',
|
||||||
|
}).url({ message: '请输入有效的仓库地址' }).min(1, { message: '仓库地址不能为空' }),
|
||||||
|
|
||||||
|
projectDir: projectDirSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新项目验证架构
|
||||||
|
*/
|
||||||
|
export const updateProjectSchema = z.object({
|
||||||
|
name: z.string({
|
||||||
|
message: '项目名称必须是字符串',
|
||||||
|
}).min(2, { message: '项目名称至少2个字符' }).max(50, { message: '项目名称不能超过50个字符' }).optional(),
|
||||||
|
|
||||||
|
description: z.string({
|
||||||
|
message: '项目描述必须是字符串',
|
||||||
|
}).max(200, { message: '项目描述不能超过200个字符' }).optional(),
|
||||||
|
|
||||||
|
repository: z.string({
|
||||||
|
message: '仓库地址必须是字符串',
|
||||||
|
}).url({ message: '请输入有效的仓库地址' }).min(1, { message: '仓库地址不能为空' }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目列表查询参数验证架构
|
||||||
|
*/
|
||||||
|
export const listProjectQuerySchema = z.object({
|
||||||
|
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),
|
||||||
|
name: z.string().optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目ID验证架构
|
||||||
|
*/
|
||||||
|
export const projectIdSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive({ message: '项目 ID 必须是正整数' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// TypeScript 类型导出
|
||||||
|
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||||
|
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||||
|
export type ListProjectQuery = z.infer<typeof listProjectQuerySchema>;
|
||||||
|
export type ProjectIdParams = z.infer<typeof projectIdSchema>;
|
||||||
227
apps/server/controllers/project/index.ts
Normal file
227
apps/server/controllers/project/index.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
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 { GitManager } from '../../libs/git-manager.ts';
|
||||||
|
import {
|
||||||
|
createProjectSchema,
|
||||||
|
updateProjectSchema,
|
||||||
|
listProjectQuerySchema,
|
||||||
|
projectIdSchema,
|
||||||
|
} from './dto.ts';
|
||||||
|
|
||||||
|
@Controller('/projects')
|
||||||
|
export class ProjectController {
|
||||||
|
// GET /api/projects - 获取项目列表
|
||||||
|
@Get('')
|
||||||
|
async list(ctx: Context) {
|
||||||
|
const query = listProjectQuerySchema.parse(ctx.query);
|
||||||
|
|
||||||
|
const whereCondition: any = {
|
||||||
|
valid: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果提供了名称搜索参数
|
||||||
|
if (query?.name) {
|
||||||
|
whereCondition.name = {
|
||||||
|
contains: query.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [total, projects] = await Promise.all([
|
||||||
|
prisma.project.count({ where: whereCondition }),
|
||||||
|
prisma.project.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
skip: query ? (query.page - 1) * query.limit : 0,
|
||||||
|
take: query?.limit,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: projects,
|
||||||
|
pagination: {
|
||||||
|
page: query?.page || 1,
|
||||||
|
limit: query?.limit || 10,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / (query?.limit || 10)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/projects/:id - 获取单个项目
|
||||||
|
@Get(':id')
|
||||||
|
async show(ctx: Context) {
|
||||||
|
const { id } = projectIdSchema.parse(ctx.params);
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
valid: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new BusinessError('项目不存在', 1002, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工作目录状态信息
|
||||||
|
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('')
|
||||||
|
async create(ctx: Context) {
|
||||||
|
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({
|
||||||
|
data: {
|
||||||
|
name: validatedData.name,
|
||||||
|
description: validatedData.description || '',
|
||||||
|
repository: validatedData.repository,
|
||||||
|
projectDir: validatedData.projectDir,
|
||||||
|
createdBy: 'system',
|
||||||
|
updatedBy: 'system',
|
||||||
|
valid: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'project',
|
||||||
|
'Created new project: %s with projectDir: %s',
|
||||||
|
project.name,
|
||||||
|
project.projectDir,
|
||||||
|
);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/projects/:id - 更新项目
|
||||||
|
@Put(':id')
|
||||||
|
async update(ctx: Context) {
|
||||||
|
const { id } = projectIdSchema.parse(ctx.params);
|
||||||
|
const validatedData = updateProjectSchema.parse(ctx.request.body);
|
||||||
|
|
||||||
|
// 检查项目是否存在
|
||||||
|
const existingProject = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
valid: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingProject) {
|
||||||
|
throw new BusinessError('项目不存在', 1002, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只更新提供的字段
|
||||||
|
const updateData: any = {
|
||||||
|
updatedBy: 'system',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validatedData.name !== undefined) {
|
||||||
|
updateData.name = validatedData.name;
|
||||||
|
}
|
||||||
|
if (validatedData.description !== undefined) {
|
||||||
|
updateData.description = validatedData.description;
|
||||||
|
}
|
||||||
|
if (validatedData.repository !== undefined) {
|
||||||
|
updateData.repository = validatedData.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('project', 'Updated project: %s', project.name);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id - 删除项目(软删除)
|
||||||
|
@Delete(':id')
|
||||||
|
async destroy(ctx: Context) {
|
||||||
|
const { id } = projectIdSchema.parse(ctx.params);
|
||||||
|
|
||||||
|
// 检查项目是否存在
|
||||||
|
const existingProject = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
valid: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingProject) {
|
||||||
|
throw new BusinessError('项目不存在', 1002, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删除:将 valid 设置为 0
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
valid: 0,
|
||||||
|
updatedBy: 'system',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('project', 'Deleted project: %s', existingProject.name);
|
||||||
|
|
||||||
|
// RESTful 删除成功返回 204 No Content
|
||||||
|
ctx.status = 204;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
apps/server/controllers/step/dto.ts
Normal file
103
apps/server/controllers/step/dto.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// 定义验证架构
|
||||||
|
export const createStepSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string({
|
||||||
|
message: '步骤名称必须是字符串',
|
||||||
|
})
|
||||||
|
.min(1, { message: '步骤名称不能为空' })
|
||||||
|
.max(100, { message: '步骤名称不能超过100个字符' }),
|
||||||
|
|
||||||
|
description: z
|
||||||
|
.string({
|
||||||
|
message: '步骤描述必须是字符串',
|
||||||
|
})
|
||||||
|
.max(500, { message: '步骤描述不能超过500个字符' })
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
order: z
|
||||||
|
.number({
|
||||||
|
message: '步骤顺序必须是数字',
|
||||||
|
})
|
||||||
|
.int()
|
||||||
|
.min(0, { message: '步骤顺序必须是非负整数' }),
|
||||||
|
|
||||||
|
script: z
|
||||||
|
.string({
|
||||||
|
message: '脚本命令必须是字符串',
|
||||||
|
})
|
||||||
|
.min(1, { message: '脚本命令不能为空' }),
|
||||||
|
|
||||||
|
pipelineId: z
|
||||||
|
.number({
|
||||||
|
message: '流水线ID必须是数字',
|
||||||
|
})
|
||||||
|
.int()
|
||||||
|
.positive({ message: '流水线ID必须是正整数' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateStepSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string({
|
||||||
|
message: '步骤名称必须是字符串',
|
||||||
|
})
|
||||||
|
.min(1, { message: '步骤名称不能为空' })
|
||||||
|
.max(100, { message: '步骤名称不能超过100个字符' })
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
description: z
|
||||||
|
.string({
|
||||||
|
message: '步骤描述必须是字符串',
|
||||||
|
})
|
||||||
|
.max(500, { message: '步骤描述不能超过500个字符' })
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
order: z
|
||||||
|
.number({
|
||||||
|
message: '步骤顺序必须是数字',
|
||||||
|
})
|
||||||
|
.int()
|
||||||
|
.min(0, { message: '步骤顺序必须是非负整数' })
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
script: z
|
||||||
|
.string({
|
||||||
|
message: '脚本命令必须是字符串',
|
||||||
|
})
|
||||||
|
.min(1, { message: '脚本命令不能为空' })
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stepIdSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive({ message: '步骤 ID 必须是正整数' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listStepsQuerySchema = z
|
||||||
|
.object({
|
||||||
|
pipelineId: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive({ message: '流水线ID必须是正整数' })
|
||||||
|
.optional(),
|
||||||
|
page: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, { message: '页码必须大于0' })
|
||||||
|
.optional()
|
||||||
|
.default(1),
|
||||||
|
limit: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, { message: '每页数量必须大于0' })
|
||||||
|
.max(100, { message: '每页数量不能超过100' })
|
||||||
|
.optional()
|
||||||
|
.default(10),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
// TypeScript 类型
|
||||||
|
export type CreateStepInput = z.infer<typeof createStepSchema>;
|
||||||
|
export type UpdateStepInput = z.infer<typeof updateStepSchema>;
|
||||||
|
export type StepIdParams = z.infer<typeof stepIdSchema>;
|
||||||
|
export type ListStepsQuery = z.infer<typeof listStepsQuerySchema>;
|
||||||
181
apps/server/controllers/step/index.ts
Normal file
181
apps/server/controllers/step/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/server/controllers/user/dto.ts
Normal file
26
apps/server/controllers/user/dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const userIdSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive({ message: '用户ID必须是正整数' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: '用户名不能为空' }),
|
||||||
|
email: z.string().email({ message: '邮箱格式不正确' }),
|
||||||
|
status: z.enum(['active', 'inactive']).optional().default('active'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
status: z.enum(['active', 'inactive']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchUserQuerySchema = z.object({
|
||||||
|
keyword: z.string().optional(),
|
||||||
|
status: z.enum(['active', 'inactive']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||||
|
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||||
|
export type SearchUserQuery = z.infer<typeof searchUserQuerySchema>;
|
||||||
123
apps/server/controllers/user/index.ts
Normal file
123
apps/server/controllers/user/index.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Context } from 'koa';
|
||||||
|
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
|
||||||
|
import { BusinessError } from '../../middlewares/exception.ts';
|
||||||
|
import {
|
||||||
|
userIdSchema,
|
||||||
|
createUserSchema,
|
||||||
|
updateUserSchema,
|
||||||
|
searchUserQuerySchema,
|
||||||
|
} from './dto.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户控制器
|
||||||
|
*/
|
||||||
|
@Controller('/user')
|
||||||
|
export class UserController {
|
||||||
|
|
||||||
|
@Get('/list')
|
||||||
|
async list(ctx: Context) {
|
||||||
|
// 模拟用户列表数据
|
||||||
|
const users = [
|
||||||
|
{ id: 1, name: 'Alice', email: 'alice@example.com', status: 'active' },
|
||||||
|
{ id: 2, name: 'Bob', email: 'bob@example.com', status: 'inactive' },
|
||||||
|
{ id: 3, name: 'Charlie', email: 'charlie@example.com', status: 'active' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/detail/:id')
|
||||||
|
async detail(ctx: Context) {
|
||||||
|
const { id } = userIdSchema.parse(ctx.params);
|
||||||
|
|
||||||
|
// 模拟根据ID查找用户
|
||||||
|
const user = {
|
||||||
|
id,
|
||||||
|
name: 'User ' + id,
|
||||||
|
email: `user${id}@example.com`,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id > 100) {
|
||||||
|
throw new BusinessError('用户不存在', 2001, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('')
|
||||||
|
async create(ctx: Context) {
|
||||||
|
const body = createUserSchema.parse(ctx.request.body);
|
||||||
|
|
||||||
|
// 模拟创建用户
|
||||||
|
const newUser = {
|
||||||
|
id: Date.now(),
|
||||||
|
...body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
status: body.status
|
||||||
|
};
|
||||||
|
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('/:id')
|
||||||
|
async update(ctx: Context) {
|
||||||
|
const { id } = userIdSchema.parse(ctx.params);
|
||||||
|
const body = updateUserSchema.parse(ctx.request.body);
|
||||||
|
|
||||||
|
// 模拟更新用户
|
||||||
|
const updatedUser = {
|
||||||
|
id,
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/:id')
|
||||||
|
async delete(ctx: Context) {
|
||||||
|
const { id } = userIdSchema.parse(ctx.params);
|
||||||
|
|
||||||
|
if (id === 1) {
|
||||||
|
throw new BusinessError('管理员账户不能删除', 2002, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟删除操作
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `用户 ${id} 已删除`,
|
||||||
|
deletedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/search')
|
||||||
|
async search(ctx: Context) {
|
||||||
|
const { keyword, status } = searchUserQuerySchema.parse(ctx.query);
|
||||||
|
|
||||||
|
// 模拟搜索逻辑
|
||||||
|
let results = [
|
||||||
|
{ id: 1, name: 'Alice', email: 'alice@example.com', status: 'active' },
|
||||||
|
{ id: 2, name: 'Bob', email: 'bob@example.com', status: 'inactive' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
results = results.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||||
|
user.email.toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
results = results.filter(user => user.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyword,
|
||||||
|
status,
|
||||||
|
total: results.length,
|
||||||
|
results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
137
apps/server/decorators/route.ts
Normal file
137
apps/server/decorators/route.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 路由元数据键
|
||||||
|
*/
|
||||||
|
export const ROUTE_METADATA_KEY = Symbol('route');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 方法类型
|
||||||
|
*/
|
||||||
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由元数据接口
|
||||||
|
*/
|
||||||
|
export interface RouteMetadata {
|
||||||
|
method: HttpMethod;
|
||||||
|
path: string;
|
||||||
|
propertyKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元数据存储(降级方案)
|
||||||
|
*/
|
||||||
|
const metadataStore = new WeakMap<any, Map<string | symbol, any>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置元数据(降级方案)
|
||||||
|
*/
|
||||||
|
function setMetadata<T = any>(key: string | symbol, value: T, target: any): void {
|
||||||
|
if (!metadataStore.has(target)) {
|
||||||
|
metadataStore.set(target, new Map());
|
||||||
|
}
|
||||||
|
metadataStore.get(target)!.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取元数据(降级方案)
|
||||||
|
*/
|
||||||
|
function getMetadata<T = any>(key: string | symbol, target: any): T | undefined {
|
||||||
|
return metadataStore.get(target)?.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建HTTP方法装饰器的工厂函数(TC39标准)
|
||||||
|
*/
|
||||||
|
function createMethodDecorator(method: HttpMethod) {
|
||||||
|
return function (path: string = '') {
|
||||||
|
return function <This, Args extends any[], Return>(
|
||||||
|
target: (this: This, ...args: Args) => Return,
|
||||||
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
|
||||||
|
) {
|
||||||
|
// 在类初始化时执行
|
||||||
|
context.addInitializer(function () {
|
||||||
|
// 使用 this.constructor 时需要类型断言
|
||||||
|
const ctor = (this as any).constructor;
|
||||||
|
|
||||||
|
// 获取现有的路由元数据
|
||||||
|
const existingRoutes: RouteMetadata[] = getMetadata(ROUTE_METADATA_KEY, ctor) || [];
|
||||||
|
|
||||||
|
// 添加新的路由元数据
|
||||||
|
const newRoute: RouteMetadata = {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
propertyKey: String(context.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
existingRoutes.push(newRoute);
|
||||||
|
|
||||||
|
// 保存路由元数据到类的构造函数上
|
||||||
|
setMetadata(ROUTE_METADATA_KEY, existingRoutes, ctor);
|
||||||
|
});
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 请求装饰器(TC39标准)
|
||||||
|
* @param path 路由路径
|
||||||
|
*/
|
||||||
|
export const Get = createMethodDecorator('GET');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST 请求装饰器(TC39标准)
|
||||||
|
* @param path 路由路径
|
||||||
|
*/
|
||||||
|
export const Post = createMethodDecorator('POST');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT 请求装饰器(TC39标准)
|
||||||
|
* @param path 路由路径
|
||||||
|
*/
|
||||||
|
export const Put = createMethodDecorator('PUT');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE 请求装饰器(TC39标准)
|
||||||
|
* @param path 路由路径
|
||||||
|
*/
|
||||||
|
export const Delete = createMethodDecorator('DELETE');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH 请求装饰器(TC39标准)
|
||||||
|
* @param path 路由路径
|
||||||
|
*/
|
||||||
|
export const Patch = createMethodDecorator('PATCH');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制器装饰器(TC39标准)
|
||||||
|
* @param prefix 路由前缀
|
||||||
|
*/
|
||||||
|
export function Controller(prefix: string = '') {
|
||||||
|
return function <T extends abstract new (...args: any) => any>(
|
||||||
|
target: T,
|
||||||
|
context: ClassDecoratorContext<T>
|
||||||
|
) {
|
||||||
|
// 在类初始化时保存控制器前缀
|
||||||
|
context.addInitializer(function () {
|
||||||
|
setMetadata('prefix', prefix, this);
|
||||||
|
});
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取控制器的路由元数据
|
||||||
|
*/
|
||||||
|
export function getRouteMetadata(ctor: any): RouteMetadata[] {
|
||||||
|
return getMetadata(ROUTE_METADATA_KEY, ctor) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取控制器的路由前缀
|
||||||
|
*/
|
||||||
|
export function getControllerPrefix(ctor: any): string {
|
||||||
|
return getMetadata('prefix', ctor) || '';
|
||||||
|
}
|
||||||
44
apps/server/generated/browser.ts
Normal file
44
apps/server/generated/browser.ts
Normal 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
|
||||||
66
apps/server/generated/client.ts
Normal file
66
apps/server/generated/client.ts
Normal 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
|
||||||
402
apps/server/generated/commonInputTypes.ts
Normal file
402
apps/server/generated/commonInputTypes.ts
Normal 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
apps/server/generated/enums.ts
Normal file
15
apps/server/generated/enums.ts
Normal 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 {}
|
||||||
230
apps/server/generated/internal/class.ts
Normal file
230
apps/server/generated/internal/class.ts
Normal 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
|
||||||
|
}
|
||||||
1106
apps/server/generated/internal/prismaNamespace.ts
Normal file
1106
apps/server/generated/internal/prismaNamespace.ts
Normal file
File diff suppressed because it is too large
Load Diff
175
apps/server/generated/internal/prismaNamespaceBrowser.ts
Normal file
175
apps/server/generated/internal/prismaNamespaceBrowser.ts
Normal 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]
|
||||||
|
|
||||||
16
apps/server/generated/models.ts
Normal file
16
apps/server/generated/models.ts
Normal 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'
|
||||||
1837
apps/server/generated/models/Deployment.ts
Normal file
1837
apps/server/generated/models/Deployment.ts
Normal file
File diff suppressed because it is too large
Load Diff
1706
apps/server/generated/models/Pipeline.ts
Normal file
1706
apps/server/generated/models/Pipeline.ts
Normal file
File diff suppressed because it is too large
Load Diff
1681
apps/server/generated/models/Project.ts
Normal file
1681
apps/server/generated/models/Project.ts
Normal file
File diff suppressed because it is too large
Load Diff
1569
apps/server/generated/models/Step.ts
Normal file
1569
apps/server/generated/models/Step.ts
Normal file
File diff suppressed because it is too large
Load Diff
1361
apps/server/generated/models/User.ts
Normal file
1361
apps/server/generated/models/User.ts
Normal file
File diff suppressed because it is too large
Load Diff
266
apps/server/libs/execution-queue.ts
Normal file
266
apps/server/libs/execution-queue.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
280
apps/server/libs/git-manager.ts
Normal file
280
apps/server/libs/git-manager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
apps/server/libs/gitea.ts
Normal file
142
apps/server/libs/gitea.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaUser {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
login_name: string;
|
||||||
|
source_id: number;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
avatar_url: string;
|
||||||
|
html_url: string;
|
||||||
|
language: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
last_login: string;
|
||||||
|
created: string;
|
||||||
|
restricted: boolean;
|
||||||
|
active: boolean;
|
||||||
|
prohibit_login: boolean;
|
||||||
|
location: string;
|
||||||
|
website: string;
|
||||||
|
description: string;
|
||||||
|
visibility: string;
|
||||||
|
followers_count: number;
|
||||||
|
following_count: number;
|
||||||
|
starred_repos_count: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Gitea {
|
||||||
|
private get config() {
|
||||||
|
return {
|
||||||
|
giteaUrl: process.env.GITEA_URL!,
|
||||||
|
clientId: process.env.GITEA_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GITEA_CLIENT_SECRET!,
|
||||||
|
redirectUri: process.env.GITEA_REDIRECT_URI!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getToken(code: string) {
|
||||||
|
const { giteaUrl, clientId, clientSecret, redirectUri } = this.config;
|
||||||
|
console.log('this.config', this.config);
|
||||||
|
const response = await fetch(
|
||||||
|
`${giteaUrl}/login/oauth/access_token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log(await response.json());
|
||||||
|
throw new Error(`Fetch failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as TokenResponse;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
*/
|
||||||
|
async getUserInfo(accessToken: string) {
|
||||||
|
const response = await fetch(`${this.config.giteaUrl}/api/v1/user`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(accessToken),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fetch failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = (await response.json()) as GiteaUser;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仓库分支列表
|
||||||
|
* @param owner 仓库拥有者
|
||||||
|
* @param repo 仓库名称
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
*/
|
||||||
|
async getBranches(owner: string, repo: string, accessToken: string) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.config.giteaUrl}/api/v1/repos/${owner}/${repo}/branches`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(accessToken),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fetch failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仓库提交记录
|
||||||
|
* @param owner 仓库拥有者
|
||||||
|
* @param repo 仓库名称
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
* @param sha 分支名称或提交SHA
|
||||||
|
*/
|
||||||
|
async getCommits(owner: string, repo: string, accessToken: string, sha?: string) {
|
||||||
|
const url = new URL(`${this.config.giteaUrl}/api/v1/repos/${owner}/${repo}/commits`);
|
||||||
|
if (sha) {
|
||||||
|
url.searchParams.append('sha', sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
url.toString(),
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(accessToken),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fetch failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaders(accessToken?: string) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (accessToken) {
|
||||||
|
headers['Authorization'] = `token ${accessToken}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gitea = new Gitea();
|
||||||
38
apps/server/libs/logger.ts
Normal file
38
apps/server/libs/logger.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private readonly logger: pino.Logger;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger = pino({
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
singleLine: true,
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
level: 'debug',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(tag: string, message: string, ...args: unknown[]) {
|
||||||
|
if (args.length > 0) {
|
||||||
|
this.logger.debug({ TAG: tag }, message, ...(args as []));
|
||||||
|
} else {
|
||||||
|
this.logger.debug({ TAG: tag }, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info(tag: string, message: string, ...args: unknown[]) {
|
||||||
|
this.logger.info({ TAG: tag }, message, ...(args as []));
|
||||||
|
}
|
||||||
|
|
||||||
|
error(tag: string, message: string, ...args: unknown[]) {
|
||||||
|
this.logger.error({ TAG: tag }, message, ...(args as []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const log = new Logger();
|
||||||
67
apps/server/libs/path-validator.ts
Normal file
67
apps/server/libs/path-validator.ts
Normal 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);
|
||||||
|
}
|
||||||
247
apps/server/libs/pipeline-template.ts
Normal file
247
apps/server/libs/pipeline-template.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/server/libs/prisma.ts
Normal file
8
apps/server/libs/prisma.ts
Normal 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 });
|
||||||
155
apps/server/libs/route-scanner.ts
Normal file
155
apps/server/libs/route-scanner.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import type Koa from 'koa';
|
||||||
|
import KoaRouter from '@koa/router';
|
||||||
|
import { getRouteMetadata, getControllerPrefix, type RouteMetadata } from '../decorators/route.ts';
|
||||||
|
import { createSuccessResponse } from '../middlewares/exception.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制器类型
|
||||||
|
*/
|
||||||
|
export interface ControllerClass {
|
||||||
|
new (...args: any[]): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由扫描器,用于自动注册装饰器标注的路由
|
||||||
|
*/
|
||||||
|
export class RouteScanner {
|
||||||
|
private router: KoaRouter;
|
||||||
|
private controllers: ControllerClass[] = [];
|
||||||
|
|
||||||
|
constructor(prefix: string = '/api') {
|
||||||
|
this.router = new KoaRouter({ prefix });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册控制器类
|
||||||
|
*/
|
||||||
|
registerController(ControllerClass: ControllerClass): void {
|
||||||
|
this.controllers.push(ControllerClass);
|
||||||
|
this.scanController(ControllerClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册多个控制器类
|
||||||
|
*/
|
||||||
|
registerControllers(controllers: ControllerClass[]): void {
|
||||||
|
controllers.forEach(controller => this.registerController(controller));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描控制器并注册路由
|
||||||
|
*/
|
||||||
|
private scanController(ControllerClass: ControllerClass): void {
|
||||||
|
// 创建控制器实例
|
||||||
|
const controllerInstance = new ControllerClass();
|
||||||
|
|
||||||
|
// 获取控制器的路由前缀
|
||||||
|
const controllerPrefix = getControllerPrefix(ControllerClass);
|
||||||
|
|
||||||
|
// 获取控制器的路由元数据
|
||||||
|
const routes: RouteMetadata[] = getRouteMetadata(ControllerClass);
|
||||||
|
|
||||||
|
// 注册每个路由
|
||||||
|
routes.forEach(route => {
|
||||||
|
const fullPath = this.buildFullPath(controllerPrefix, route.path);
|
||||||
|
const handler = this.wrapControllerMethod(controllerInstance, route.propertyKey);
|
||||||
|
|
||||||
|
// 根据HTTP方法注册路由
|
||||||
|
switch (route.method) {
|
||||||
|
case 'GET':
|
||||||
|
this.router.get(fullPath, handler);
|
||||||
|
break;
|
||||||
|
case 'POST':
|
||||||
|
this.router.post(fullPath, handler);
|
||||||
|
break;
|
||||||
|
case 'PUT':
|
||||||
|
this.router.put(fullPath, handler);
|
||||||
|
break;
|
||||||
|
case 'DELETE':
|
||||||
|
this.router.delete(fullPath, handler);
|
||||||
|
break;
|
||||||
|
case 'PATCH':
|
||||||
|
this.router.patch(fullPath, handler);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`未支持的HTTP方法: ${route.method}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建完整的路由路径
|
||||||
|
*/
|
||||||
|
private buildFullPath(controllerPrefix: string, routePath: string): string {
|
||||||
|
// 清理和拼接路径
|
||||||
|
const cleanControllerPrefix = controllerPrefix.replace(/^\/+|\/+$/g, '');
|
||||||
|
const cleanRoutePath = routePath.replace(/^\/+|\/+$/g, '');
|
||||||
|
|
||||||
|
let fullPath = '';
|
||||||
|
if (cleanControllerPrefix) {
|
||||||
|
fullPath += '/' + cleanControllerPrefix;
|
||||||
|
}
|
||||||
|
if (cleanRoutePath) {
|
||||||
|
fullPath += '/' + cleanRoutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果路径为空,返回根路径
|
||||||
|
return fullPath || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 包装控制器方法,统一处理响应格式
|
||||||
|
*/
|
||||||
|
private wrapControllerMethod(instance: any, methodName: string) {
|
||||||
|
return async (ctx: Koa.Context, next: Koa.Next) => {
|
||||||
|
// 调用控制器方法
|
||||||
|
const method = instance[methodName];
|
||||||
|
if (typeof method !== 'function') {
|
||||||
|
ctx.throw(401, 'Not Found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定this并调用方法
|
||||||
|
const result = await method.call(instance, ctx, next) ?? null;
|
||||||
|
|
||||||
|
ctx.body = createSuccessResponse(result);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Koa路由器实例,用于应用到Koa应用中
|
||||||
|
*/
|
||||||
|
getRouter(): KoaRouter {
|
||||||
|
return this.router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用路由到Koa应用
|
||||||
|
*/
|
||||||
|
applyToApp(app: Koa): void {
|
||||||
|
app.use(this.router.routes());
|
||||||
|
app.use(this.router.allowedMethods());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已注册的路由信息(用于调试)
|
||||||
|
*/
|
||||||
|
getRegisteredRoutes(): Array<{ method: string; path: string; controller: string; action: string }> {
|
||||||
|
const routes: Array<{ method: string; path: string; controller: string; action: string }> = [];
|
||||||
|
|
||||||
|
this.controllers.forEach(ControllerClass => {
|
||||||
|
const controllerPrefix = getControllerPrefix(ControllerClass);
|
||||||
|
const routeMetadata = getRouteMetadata(ControllerClass);
|
||||||
|
|
||||||
|
routeMetadata.forEach(route => {
|
||||||
|
routes.push({
|
||||||
|
method: route.method,
|
||||||
|
path: this.buildFullPath(controllerPrefix, route.path),
|
||||||
|
controller: ControllerClass.name,
|
||||||
|
action: route.propertyKey
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/server/middlewares/authorization.ts
Normal file
22
apps/server/middlewares/authorization.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type Koa from 'koa';
|
||||||
|
import type { Middleware } from './types.ts';
|
||||||
|
|
||||||
|
export class Authorization implements Middleware {
|
||||||
|
private readonly ignoreAuth = [
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/info',
|
||||||
|
'/api/auth/url',
|
||||||
|
];
|
||||||
|
|
||||||
|
apply(app: Koa) {
|
||||||
|
app.use(async (ctx: Koa.Context, next: Koa.Next) => {
|
||||||
|
if (this.ignoreAuth.includes(ctx.path)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (ctx.session.user == null) {
|
||||||
|
ctx.throw(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/middlewares/body-parser.ts
Normal file
12
apps/server/middlewares/body-parser.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import bodyParser from 'koa-bodyparser';
|
||||||
|
import type Koa from 'koa';
|
||||||
|
import type { Middleware } from './types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体解析中间件
|
||||||
|
*/
|
||||||
|
export class BodyParser implements Middleware {
|
||||||
|
apply(app: Koa): void {
|
||||||
|
app.use(bodyParser());
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/server/middlewares/cors.ts
Normal file
17
apps/server/middlewares/cors.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import cors from '@koa/cors';
|
||||||
|
import type Koa from 'koa';
|
||||||
|
import type { Middleware } from './types.ts';
|
||||||
|
|
||||||
|
export class CORS implements Middleware {
|
||||||
|
apply(app: Koa) {
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
credentials: true,
|
||||||
|
allowHeaders: ['Content-Type'],
|
||||||
|
origin(ctx) {
|
||||||
|
return ctx.get('Origin') || '*';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
apps/server/middlewares/exception.ts
Normal file
149
apps/server/middlewares/exception.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type Koa from 'koa';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Middleware } from './types.ts';
|
||||||
|
import { log } from '../libs/logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一响应体结构
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number; // 状态码:0表示成功,其他表示失败
|
||||||
|
message: string; // 响应消息
|
||||||
|
data?: T; // 响应数据
|
||||||
|
timestamp: number; // 时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义业务异常类
|
||||||
|
*/
|
||||||
|
export class BusinessError extends Error {
|
||||||
|
public code: number;
|
||||||
|
public httpStatus: number;
|
||||||
|
|
||||||
|
constructor(message: string, code = 1000, httpStatus = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BusinessError';
|
||||||
|
this.code = code;
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常处理中间件
|
||||||
|
*/
|
||||||
|
export class Exception implements Middleware {
|
||||||
|
apply(app: Koa): void {
|
||||||
|
app.use(async (ctx, next) => {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
// 如果没有设置响应体,则返回404
|
||||||
|
if (ctx.status === 404) {
|
||||||
|
this.sendResponse(ctx, 404, 'Not Found', null, 404);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Exception', 'catch error: %o', error);
|
||||||
|
this.handleError(ctx, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理错误
|
||||||
|
*/
|
||||||
|
private handleError(ctx: Koa.Context, error: any): void {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
// Zod 参数验证错误
|
||||||
|
const firstError = error.issues[0];
|
||||||
|
const errorMessage = firstError?.message || '参数验证失败';
|
||||||
|
const fieldPath = firstError?.path?.join('.') || 'unknown';
|
||||||
|
|
||||||
|
log.info('Exception', 'Zod validation failed: %s at %s', errorMessage, fieldPath);
|
||||||
|
this.sendResponse(ctx, 1003, errorMessage, {
|
||||||
|
field: fieldPath,
|
||||||
|
validationErrors: error.issues.map(issue => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
message: issue.message,
|
||||||
|
code: issue.code,
|
||||||
|
}))
|
||||||
|
}, 400);
|
||||||
|
} else if (error instanceof BusinessError) {
|
||||||
|
// 业务异常
|
||||||
|
this.sendResponse(ctx, error.code, error.message, null, error.httpStatus);
|
||||||
|
} else if (error.status) {
|
||||||
|
// Koa HTTP 错误
|
||||||
|
const message =
|
||||||
|
error.status === 401
|
||||||
|
? '未授权访问'
|
||||||
|
: error.status === 403
|
||||||
|
? '禁止访问'
|
||||||
|
: error.status === 404
|
||||||
|
? '资源不存在'
|
||||||
|
: error.status === 422
|
||||||
|
? '请求参数错误'
|
||||||
|
: error.message || '请求失败';
|
||||||
|
|
||||||
|
this.sendResponse(ctx, error.status, message, null, error.status);
|
||||||
|
} else {
|
||||||
|
// 系统异常
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const message = isDev ? error.message : '服务器内部错误';
|
||||||
|
const data = isDev ? { stack: error.stack } : null;
|
||||||
|
|
||||||
|
this.sendResponse(ctx, 500, message, data, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送统一响应
|
||||||
|
*/
|
||||||
|
private sendResponse(
|
||||||
|
ctx: Koa.Context,
|
||||||
|
code: number,
|
||||||
|
message: string,
|
||||||
|
data: any = null,
|
||||||
|
httpStatus = 200,
|
||||||
|
): void {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.status = httpStatus;
|
||||||
|
ctx.body = response;
|
||||||
|
ctx.type = 'application/json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建成功响应的辅助函数
|
||||||
|
*/
|
||||||
|
export function createSuccessResponse<T>(
|
||||||
|
data: T,
|
||||||
|
message = 'success',
|
||||||
|
): ApiResponse<T> {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建失败响应的辅助函数
|
||||||
|
*/
|
||||||
|
export function createErrorResponse(
|
||||||
|
code: number,
|
||||||
|
message: string,
|
||||||
|
data?: any,
|
||||||
|
): ApiResponse {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
32
apps/server/middlewares/index.ts
Normal file
32
apps/server/middlewares/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Router } from './router.ts';
|
||||||
|
import { Exception } from './exception.ts';
|
||||||
|
import { BodyParser } from './body-parser.ts';
|
||||||
|
import { Session } from './session.ts';
|
||||||
|
import { CORS } from './cors.ts';
|
||||||
|
import { HttpLogger } from './logger.ts';
|
||||||
|
import type Koa from 'koa';
|
||||||
|
import { Authorization } from './authorization.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化中间件
|
||||||
|
* @param app Koa
|
||||||
|
*/
|
||||||
|
export function initMiddlewares(app: Koa) {
|
||||||
|
// 日志中间件需要最早注册,记录所有请求
|
||||||
|
new HttpLogger().apply(app);
|
||||||
|
|
||||||
|
// 全局异常处理中间件必须最先注册
|
||||||
|
new Exception().apply(app);
|
||||||
|
|
||||||
|
// Session 中间件需要在请求体解析之前注册
|
||||||
|
new Session().apply(app);
|
||||||
|
|
||||||
|
new CORS().apply(app);
|
||||||
|
|
||||||
|
new Authorization().apply(app);
|
||||||
|
|
||||||
|
// 请求体解析中间件
|
||||||
|
new BodyParser().apply(app);
|
||||||
|
|
||||||
|
new Router().apply(app);
|
||||||
|
}
|
||||||
14
apps/server/middlewares/logger.ts
Normal file
14
apps/server/middlewares/logger.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Koa, { type Context } from 'koa';
|
||||||
|
import { log } from '../libs/logger.ts';
|
||||||
|
import type { Middleware } from './types.ts';
|
||||||
|
|
||||||
|
export class HttpLogger implements Middleware {
|
||||||
|
apply(app: Koa): void {
|
||||||
|
app.use(async (ctx: Context, next: Koa.Next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await next();
|
||||||
|
const ms = Date.now() - start;
|
||||||
|
log.info('HTTP', `${ctx.method} ${ctx.url} - ${ms}ms`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/server/middlewares/router.ts
Normal file
78
apps/server/middlewares/router.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import KoaRouter from '@koa/router';
|
||||||
|
import type Koa from 'koa';
|
||||||
|
import type { Middleware } from './types.ts';
|
||||||
|
import { RouteScanner } from '../libs/route-scanner.ts';
|
||||||
|
import {
|
||||||
|
ProjectController,
|
||||||
|
UserController,
|
||||||
|
AuthController,
|
||||||
|
DeploymentController,
|
||||||
|
PipelineController,
|
||||||
|
StepController,
|
||||||
|
GitController
|
||||||
|
} from '../controllers/index.ts';
|
||||||
|
import { log } from '../libs/logger.ts';
|
||||||
|
|
||||||
|
export class Router implements Middleware {
|
||||||
|
private router: KoaRouter;
|
||||||
|
private routeScanner: RouteScanner;
|
||||||
|
private readonly TAG = 'Router';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router = new KoaRouter({
|
||||||
|
prefix: '/api',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化路由扫描器
|
||||||
|
this.routeScanner = new RouteScanner('/api');
|
||||||
|
|
||||||
|
// 注册装饰器路由
|
||||||
|
this.registerDecoratorRoutes();
|
||||||
|
|
||||||
|
// 注册传统路由(向后兼容)
|
||||||
|
this.registerTraditionalRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册装饰器路由
|
||||||
|
*/
|
||||||
|
private registerDecoratorRoutes(): void {
|
||||||
|
// 注册所有使用装饰器的控制器
|
||||||
|
this.routeScanner.registerControllers([
|
||||||
|
ProjectController,
|
||||||
|
UserController,
|
||||||
|
AuthController,
|
||||||
|
DeploymentController,
|
||||||
|
PipelineController,
|
||||||
|
StepController,
|
||||||
|
GitController
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 输出注册的路由信息
|
||||||
|
const routes = this.routeScanner.getRegisteredRoutes();
|
||||||
|
log.debug(this.TAG, '装饰器路由注册完成:');
|
||||||
|
routes.forEach((route) => {
|
||||||
|
log.debug(
|
||||||
|
this.TAG,
|
||||||
|
` ${route.method} ${route.path} -> ${route.controller}.${route.action}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册传统路由(向后兼容)
|
||||||
|
*/
|
||||||
|
private registerTraditionalRoutes(): void {
|
||||||
|
// 保持对老版本的兼容,如果需要可以在这里注册非装饰器路由
|
||||||
|
// this.router.get('/application/list-legacy', wrapController(application.list));
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(app: Koa) {
|
||||||
|
// 应用装饰器路由
|
||||||
|
this.routeScanner.applyToApp(app);
|
||||||
|
|
||||||
|
// 应用传统路由
|
||||||
|
app.use(this.router.routes());
|
||||||
|
app.use(this.router.allowedMethods());
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/server/middlewares/session.ts
Normal file
24
apps/server/middlewares/session.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import session from 'koa-session';
|
||||||
|
import type Koa from 'koa';
|
||||||
|
import type { Middleware } from './types.ts';
|
||||||
|
|
||||||
|
export class Session implements Middleware {
|
||||||
|
apply(app: Koa): void {
|
||||||
|
app.keys = ['foka-ci'];
|
||||||
|
app.use(
|
||||||
|
session(
|
||||||
|
{
|
||||||
|
key: 'foka.sid',
|
||||||
|
maxAge: 86400000,
|
||||||
|
autoCommit: true /** (boolean) automatically commit headers (default true) */,
|
||||||
|
overwrite: true /** (boolean) can overwrite or not (default true) */,
|
||||||
|
httpOnly: true /** (boolean) httpOnly or not (default true) */,
|
||||||
|
signed: true /** (boolean) signed or not (default true) */,
|
||||||
|
rolling: false /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */,
|
||||||
|
renew: false /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/,
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/server/middlewares/types.ts
Normal file
5
apps/server/middlewares/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type Koa from 'koa';
|
||||||
|
|
||||||
|
export abstract class Middleware {
|
||||||
|
abstract apply(app: Koa, options?: unknown): void;
|
||||||
|
}
|
||||||
39
apps/server/package.json
Normal file
39
apps/server/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch ./app.ts"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"type": "module",
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@koa/cors": "^5.0.0",
|
||||||
|
"@koa/router": "^14.0.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-bodyparser": "^4.4.1",
|
||||||
|
"koa-session": "^7.0.2",
|
||||||
|
"pino": "^9.9.1",
|
||||||
|
"pino-pretty": "^13.1.1",
|
||||||
|
"zod": "^4.1.5",
|
||||||
|
"zx": "^8.8.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node-ts": "^23.6.1",
|
||||||
|
"@tsconfig/node22": "^22.0.2",
|
||||||
|
"@types/koa": "^3.0.0",
|
||||||
|
"@types/koa-bodyparser": "^4.3.12",
|
||||||
|
"@types/koa__cors": "^5.0.0",
|
||||||
|
"@types/koa__router": "^12.0.4",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"prisma": "^7.0.0",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/server/prisma.config.ts
Normal file
19
apps/server/prisma.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
apps/server/prisma/data/dev.db
Normal file
BIN
apps/server/prisma/data/dev.db
Normal file
Binary file not shown.
95
apps/server/prisma/schema.prisma
Normal file
95
apps/server/prisma/schema.prisma
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
repository String
|
||||||
|
projectDir String @unique // 项目工作目录路径(必填)
|
||||||
|
// Relations
|
||||||
|
deployments Deployment[]
|
||||||
|
pipelines Pipeline[]
|
||||||
|
|
||||||
|
valid Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdBy String
|
||||||
|
updatedBy String
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String
|
||||||
|
login String
|
||||||
|
email String
|
||||||
|
avatar_url String?
|
||||||
|
active Boolean @default(true)
|
||||||
|
valid Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdBy 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
|
||||||
|
}
|
||||||
3
apps/server/runners/index.ts
Normal file
3
apps/server/runners/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { PipelineRunner } from './pipeline-runner';
|
||||||
|
|
||||||
|
export { PipelineRunner };
|
||||||
28
apps/server/runners/mq-interface.ts
Normal file
28
apps/server/runners/mq-interface.ts
Normal 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;
|
||||||
|
}
|
||||||
304
apps/server/runners/pipeline-runner.ts
Normal file
304
apps/server/runners/pipeline-runner.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/tsconfig.json
Normal file
13
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"@tsconfig/node22/tsconfig.json",
|
||||||
|
"@tsconfig/node-ts/tsconfig.json"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2023",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/web/.env
Normal file
1
apps/web/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
BASE_URL=http://192.168.1.36:3001
|
||||||
38
apps/web/package.json
Normal file
38
apps/web/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rsbuild build",
|
||||||
|
"check": "biome check --write",
|
||||||
|
"dev": "rsbuild dev --open",
|
||||||
|
"format": "biome format --write",
|
||||||
|
"preview": "rsbuild preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@arco-design/web-react": "^2.66.8",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router": "^7.8.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@arco-plugins/unplugin-react": "2.0.0-beta.5",
|
||||||
|
"@rsbuild/core": "^1.6.7",
|
||||||
|
"@rsbuild/plugin-less": "^1.5.0",
|
||||||
|
"@rsbuild/plugin-react": "^1.4.2",
|
||||||
|
"@rsbuild/plugin-svgr": "^1.2.2",
|
||||||
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
|
"@types/react": "^18.3.24",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321"
|
||||||
|
}
|
||||||
26
apps/web/rsbuild.config.ts
Normal file
26
apps/web/rsbuild.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ArcoDesignPlugin } from '@arco-plugins/unplugin-react';
|
||||||
|
import { defineConfig } from '@rsbuild/core';
|
||||||
|
import { pluginLess } from '@rsbuild/plugin-less';
|
||||||
|
import { pluginReact } from '@rsbuild/plugin-react';
|
||||||
|
import { pluginSvgr } from '@rsbuild/plugin-svgr';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [pluginReact(), pluginLess(), pluginSvgr()],
|
||||||
|
html: {
|
||||||
|
title: 'Foka CI',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
define: {
|
||||||
|
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
rspack: {
|
||||||
|
plugins: [
|
||||||
|
new ArcoDesignPlugin({
|
||||||
|
defaultLanguage: 'zh-CN',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
31
apps/web/src/assets/images/gitea.svg
Normal file
31
apps/web/src/assets/images/gitea.svg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||||
|
y="0px" viewBox="0 0 640 640" style="enable-background:new 0 0 640 640;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8
|
||||||
|
c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4
|
||||||
|
c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2
|
||||||
|
c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5
|
||||||
|
c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5
|
||||||
|
c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3
|
||||||
|
c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1
|
||||||
|
C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4
|
||||||
|
c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7
|
||||||
|
S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55
|
||||||
|
c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8
|
||||||
|
l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
|
||||||
|
<path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4
|
||||||
|
c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1
|
||||||
|
c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9
|
||||||
|
c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3
|
||||||
|
c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3
|
||||||
|
c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29
|
||||||
|
c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8
|
||||||
|
C343.2,346.5,335,363.3,326.8,380.1z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
9
apps/web/src/assets/images/logo.svg
Normal file
9
apps/web/src/assets/images/logo.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.8125 17.2932C32.8125 19.6071 32.3208 21.8488 31.4779 23.8735L25.6476 17.944C26.35 16.787 26.7012 15.4131 26.7012 13.9669C26.7715 9.84522 23.47 6.44662 19.3958 6.44662C17.9207 6.44662 16.586 6.88048 15.4621 7.60359L10.0533 2.10799C12.0904 1.16795 14.2679 0.661774 16.6562 0.661774C25.5773 0.661774 32.8125 8.10976 32.8125 17.2932ZM7.80543 3.40958L13.6357 9.41135C12.6523 10.7129 12.0904 12.3038 12.0904 14.0392C12.0904 18.2332 15.3918 21.6318 19.466 21.6318C21.1519 21.6318 22.6973 21.0534 23.9617 20.041L30.2134 26.4767C27.2632 30.9599 22.3461 33.9246 16.6562 33.9246C7.73519 33.9246 0.5 26.4767 0.5 17.2932C0.5 11.436 3.38003 6.37431 7.80543 3.40958ZM19.466 18.7394C22.2056 18.7394 24.3832 16.4978 24.3832 13.6777C24.3832 10.8576 22.2056 8.61594 19.466 8.61594C16.7265 8.61594 14.5489 10.8576 14.5489 13.6777C14.5489 16.4978 16.7265 18.7394 19.466 18.7394Z" fill="url(#paint0_linear)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear" x1="6.88952" y1="1.73016" x2="27.8689" y2="33.2773" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#73DFE7"/>
|
||||||
|
<stop offset="1" stop-color="#0095F7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
0
src/env.d.ts → apps/web/src/env.d.ts
vendored
0
src/env.d.ts → apps/web/src/env.d.ts
vendored
18
apps/web/src/hooks/useAsyncEffect.ts
Normal file
18
apps/web/src/hooks/useAsyncEffect.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useAsyncEffect(
|
||||||
|
effect: () => Promise<void | (() => void)>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
) {
|
||||||
|
const callback = useCallback(effect, [...deps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanupPromise = callback();
|
||||||
|
return () => {
|
||||||
|
if (cleanupPromise instanceof Promise) {
|
||||||
|
cleanupPromise.then(cleanup => cleanup && cleanup());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [callback]);
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import React from 'react';
|
|
||||||
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 '@arco-design/web-react/es/_util/react-19-adapter'
|
||||||
|
|
||||||
const rootEl = document.getElementById('root');
|
const rootEl = document.getElementById('root');
|
||||||
|
|
||||||
if (rootEl) {
|
if (rootEl) {
|
||||||
const root = ReactDOM.createRoot(rootEl);
|
const root = ReactDOM.createRoot(rootEl);
|
||||||
|
useGlobalStore.getState().refreshUser();
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<App />
|
||||||
<App />
|
</BrowserRouter>,
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
23
apps/web/src/pages/App.tsx
Normal file
23
apps/web/src/pages/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Env from '@pages/env';
|
||||||
|
import Home from '@pages/home';
|
||||||
|
import Login from '@pages/login';
|
||||||
|
import ProjectDetail from '@pages/project/detail';
|
||||||
|
import ProjectList from '@pages/project/list';
|
||||||
|
import { Navigate, Route, Routes } from 'react-router';
|
||||||
|
|
||||||
|
import '@styles/index.css';
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />}>
|
||||||
|
<Route index element={<Navigate to="project" replace />} />
|
||||||
|
<Route path="project" element={<ProjectList />} />
|
||||||
|
<Route path="project/:id" element={<ProjectDetail />} />
|
||||||
|
<Route path="env" element={<Env />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
5
apps/web/src/pages/env/index.tsx
vendored
Normal file
5
apps/web/src/pages/env/index.tsx
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
function Env() {
|
||||||
|
return <div>env page</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Env;
|
||||||
94
apps/web/src/pages/home/index.tsx
Normal file
94
apps/web/src/pages/home/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Avatar, Dropdown, Layout, Menu } from '@arco-design/web-react';
|
||||||
|
import {
|
||||||
|
IconApps,
|
||||||
|
IconExport,
|
||||||
|
IconMenuFold,
|
||||||
|
IconMenuUnfold,
|
||||||
|
IconRobot,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
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 { useGlobalStore } from '../../stores/global';
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const globalStore = useGlobalStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="h-screen w-full">
|
||||||
|
<Layout.Sider
|
||||||
|
collapsible
|
||||||
|
onCollapse={setCollapsed}
|
||||||
|
trigger={
|
||||||
|
collapsed ? (
|
||||||
|
<IconMenuUnfold fontSize={16} />
|
||||||
|
) : (
|
||||||
|
<IconMenuFold fontSize={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center justify-center h-[56px]">
|
||||||
|
<Logo />
|
||||||
|
{!collapsed && <h2 className="ml-4 text-xl font-medium">Foka CI</h2>}
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
className="flex-1"
|
||||||
|
defaultOpenKeys={['0']}
|
||||||
|
defaultSelectedKeys={['0_1']}
|
||||||
|
collapse={collapsed}
|
||||||
|
>
|
||||||
|
<Menu.Item key="0">
|
||||||
|
<Link to="/project">
|
||||||
|
<IconApps fontSize={16} />
|
||||||
|
<span>项目管理</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item key="1">
|
||||||
|
<Link to="/env">
|
||||||
|
<IconRobot fontSize={16} />
|
||||||
|
环境管理
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
</Layout.Sider>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header className="h-14 border-b-gray-100 border-b-[1px]">
|
||||||
|
<div className="flex items-center justify-end px-4 h-full">
|
||||||
|
<Dropdown
|
||||||
|
droplist={
|
||||||
|
<Menu className="px-3">
|
||||||
|
<Menu.Item key="1" onClick={loginService.logout}>
|
||||||
|
<IconExport />
|
||||||
|
<span className="ml-2">退出登录</span>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-2 rounded-xl cursor-pointer flex items-center hover:bg-gray-100">
|
||||||
|
<Avatar
|
||||||
|
size={28}
|
||||||
|
className="border-gray-300 border-[1px] border-solid"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="avatar"
|
||||||
|
src={globalStore.user?.avatar_url.replace('https', 'http')}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<span className="ml-2 font-semibold text-gray-500">
|
||||||
|
{globalStore.user?.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content className="overflow-y-auto bg-gray-100">
|
||||||
|
<Outlet />
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
43
apps/web/src/pages/login/index.tsx
Normal file
43
apps/web/src/pages/login/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Button } from '@arco-design/web-react';
|
||||||
|
import Gitea from '@assets/images/gitea.svg?react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
import { loginService } from './service';
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const authCode = searchParams.get('code');
|
||||||
|
|
||||||
|
const onLoginClick = async () => {
|
||||||
|
const url = await loginService.getAuthUrl();
|
||||||
|
if (url) {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authCode) {
|
||||||
|
loginService.login(authCode, navigate);
|
||||||
|
}
|
||||||
|
}, [authCode, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-[100vh]">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
color="green"
|
||||||
|
shape="round"
|
||||||
|
size="large"
|
||||||
|
onClick={onLoginClick}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Gitea className="w-5 h-5" />
|
||||||
|
<span>Gitea 授权登录</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
51
apps/web/src/pages/login/service.ts
Normal file
51
apps/web/src/pages/login/service.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Message, Notification } from '@arco-design/web-react';
|
||||||
|
import { net } from '@shared';
|
||||||
|
import type { NavigateFunction } from 'react-router';
|
||||||
|
import { useGlobalStore } from '../../stores/global';
|
||||||
|
import type { AuthLoginResponse, AuthURLResponse } from './types';
|
||||||
|
|
||||||
|
class LoginService {
|
||||||
|
async getAuthUrl() {
|
||||||
|
const { code, data } = await net.request<AuthURLResponse>({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/url',
|
||||||
|
params: {
|
||||||
|
redirect: encodeURIComponent(`${location.origin}/login`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (code === 0) {
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(authCode: string, navigate: NavigateFunction) {
|
||||||
|
const { data, code } = await net.request<AuthLoginResponse>({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
data: {
|
||||||
|
code: authCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (code === 0) {
|
||||||
|
useGlobalStore.getState().setUser(data);
|
||||||
|
navigate('/');
|
||||||
|
Notification.success({
|
||||||
|
title: '提示',
|
||||||
|
content: '登录成功',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
const { code } = await net.request<AuthURLResponse>({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/logout',
|
||||||
|
});
|
||||||
|
if (code === 0) {
|
||||||
|
Message.success('登出成功');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginService = new LoginService();
|
||||||
15
apps/web/src/pages/login/types.ts
Normal file
15
apps/web/src/pages/login/types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { APIResponse } from '@shared';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar_url: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthURLResponse = APIResponse<{
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AuthLoginResponse = APIResponse<User>;
|
||||||
256
apps/web/src/pages/project/detail/components/DeployModal.tsx
Normal file
256
apps/web/src/pages/project/detail/components/DeployModal.tsx
Normal 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;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { List, Space, Tag } from '@arco-design/web-react';
|
||||||
|
import { formatDateTime } from '../../../../utils/time';
|
||||||
|
import type { Deployment } from '../../types';
|
||||||
|
|
||||||
|
interface DeployRecordItemProps {
|
||||||
|
item: Deployment;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeployRecordItem({
|
||||||
|
item,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: DeployRecordItemProps) {
|
||||||
|
// 状态标签渲染函数
|
||||||
|
const getStatusTag = (status: Deployment['status']) => {
|
||||||
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||||||
|
success: { color: 'green', text: '成功' },
|
||||||
|
running: { color: 'blue', text: '运行中' },
|
||||||
|
failed: { color: 'red', text: '失败' },
|
||||||
|
pending: { color: 'orange', text: '等待中' },
|
||||||
|
};
|
||||||
|
const config = statusMap[status];
|
||||||
|
return <Tag color={config.color}>{config.text}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 环境标签渲染函数
|
||||||
|
const getEnvTag = (env: string) => {
|
||||||
|
const envMap: Record<string, { color: string; text: string }> = {
|
||||||
|
production: { color: 'red', text: '生产环境' },
|
||||||
|
staging: { color: 'orange', text: '预发布环境' },
|
||||||
|
development: { color: 'blue', text: '开发环境' },
|
||||||
|
};
|
||||||
|
const config = envMap[env] || { color: 'gray', text: env };
|
||||||
|
return <Tag color={config.color}>{config.text}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
key={item.id}
|
||||||
|
className={`cursor-pointer transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-50 border-l-4 border-blue-500'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${
|
||||||
|
isSelected ? 'text-blue-600' : 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
#{item.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||||
|
{item.commitHash?.substring(0, 7)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div className="mt-2">
|
||||||
|
<Space size="medium" wrap>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
分支:{' '}
|
||||||
|
<span className="font-medium text-gray-700">{item.branch}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
环境: {getEnvTag(item.env || 'unknown')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
状态: {getStatusTag(item.status)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
执行时间:{' '}
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{formatDateTime(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeployRecordItem;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Button, Switch, Tag, Typography } from '@arco-design/web-react';
|
||||||
|
import {
|
||||||
|
IconDelete,
|
||||||
|
IconDragArrow,
|
||||||
|
IconEdit,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import type { Step } from '../../types';
|
||||||
|
|
||||||
|
interface StepWithEnabled extends Step {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineStepItemProps {
|
||||||
|
step: StepWithEnabled;
|
||||||
|
index: number;
|
||||||
|
pipelineId: number;
|
||||||
|
onToggle: (pipelineId: number, stepId: number, enabled: boolean) => void;
|
||||||
|
onEdit: (pipelineId: number, step: StepWithEnabled) => void;
|
||||||
|
onDelete: (pipelineId: number, stepId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineStepItem({
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
pipelineId,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: PipelineStepItemProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: step.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`bg-gray-50 rounded-lg p-4 ${isDragging ? 'shadow-lg z-10' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<IconDragArrow className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Typography.Title heading={6} className="!m-0">
|
||||||
|
{step.name}
|
||||||
|
</Typography.Title>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={step.enabled}
|
||||||
|
onChange={(enabled) => onToggle(pipelineId, step.id, enabled)}
|
||||||
|
/>
|
||||||
|
{!step.enabled && (
|
||||||
|
<Tag color="gray" size="small">
|
||||||
|
已禁用
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<pre className="whitespace-pre-wrap break-words">{step.script}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded-md p-1 transition-all duration-200"
|
||||||
|
onClick={() => onEdit(pipelineId, step)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
className="text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md p-1 transition-all duration-200"
|
||||||
|
onClick={() => onDelete(pipelineId, step.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PipelineStepItem;
|
||||||
1294
apps/web/src/pages/project/detail/index.tsx
Normal file
1294
apps/web/src/pages/project/detail/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
226
apps/web/src/pages/project/detail/service.ts
Normal file
226
apps/web/src/pages/project/detail/service.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { type APIResponse, net } from '@shared';
|
||||||
|
import type { Branch, Commit, Deployment, Pipeline, Project, Step, CreateDeploymentRequest } from '../types';
|
||||||
|
|
||||||
|
class DetailService {
|
||||||
|
async getProject(id: string) {
|
||||||
|
const { data } = await net.request<APIResponse<Project>>({
|
||||||
|
url: `/api/projects/${id}`,
|
||||||
|
});
|
||||||
|
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();
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { projectService } from '../service';
|
||||||
|
import type { Project } from '../../types';
|
||||||
|
|
||||||
|
interface CreateProjectModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSuccess: (newProject: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateProjectModal({
|
||||||
|
visible,
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
}: CreateProjectModalProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validate();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const newProject = await projectService.create(values);
|
||||||
|
|
||||||
|
Message.success('项目创建成功');
|
||||||
|
onSuccess(newProject);
|
||||||
|
form.resetFields();
|
||||||
|
onCancel();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建项目失败:', error);
|
||||||
|
Message.error('创建项目失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.resetFields();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="新建项目"
|
||||||
|
visible={visible}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCancel}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
style={{ width: 500 }}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" autoComplete="off">
|
||||||
|
<Form.Item
|
||||||
|
label="项目名称"
|
||||||
|
field="name"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入项目名称' },
|
||||||
|
{ minLength: 2, message: '项目名称至少2个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入项目名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="项目描述"
|
||||||
|
field="description"
|
||||||
|
rules={[{ maxLength: 200, message: '项目描述不能超过200个字符' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="请输入项目描述(可选)"
|
||||||
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="仓库地址"
|
||||||
|
field="repository"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入仓库地址' },
|
||||||
|
{
|
||||||
|
type: 'url',
|
||||||
|
message: '请输入有效的仓库地址',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入仓库地址,如: https://github.com/user/repo" />
|
||||||
|
</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>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateProjectModal;
|
||||||
119
apps/web/src/pages/project/list/components/EditProjectModal.tsx
Normal file
119
apps/web/src/pages/project/list/components/EditProjectModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { projectService } from '../service';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
|
||||||
|
interface EditProjectModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
project: Project | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSuccess: (updatedProject: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditProjectModal({
|
||||||
|
visible,
|
||||||
|
project,
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
}: EditProjectModalProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 当项目信息变化时,更新表单数据
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (project && visible) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
repository: project.repository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [project, visible, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validate();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const updatedProject = await projectService.update(project.id, values);
|
||||||
|
|
||||||
|
Message.success('项目更新成功');
|
||||||
|
onSuccess(updatedProject);
|
||||||
|
onCancel();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新项目失败:', error);
|
||||||
|
Message.error('更新项目失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.resetFields();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="编辑项目"
|
||||||
|
visible={visible}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCancel}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
style={{ width: 500 }}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" autoComplete="off">
|
||||||
|
<Form.Item
|
||||||
|
label="项目名称"
|
||||||
|
field="name"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入项目名称' },
|
||||||
|
{ minLength: 2, message: '项目名称至少2个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入项目名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="项目描述"
|
||||||
|
field="description"
|
||||||
|
rules={[{ maxLength: 200, message: '项目描述不能超过200个字符' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="请输入项目描述"
|
||||||
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="仓库地址"
|
||||||
|
field="repository"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入仓库地址' },
|
||||||
|
{
|
||||||
|
type: 'url',
|
||||||
|
message: '请输入有效的仓库地址',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入仓库地址,如: https://github.com/user/repo" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditProjectModal;
|
||||||
127
apps/web/src/pages/project/list/components/ProjectCard.tsx
Normal file
127
apps/web/src/pages/project/list/components/ProjectCard.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Card,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import {
|
||||||
|
IconBranch,
|
||||||
|
IconCalendar,
|
||||||
|
IconCloud,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
import IconGitea from '@assets/images/gitea.svg?react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import type { Project } from '../../types';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ project }: ProjectCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// 获取环境信息
|
||||||
|
const environments = [
|
||||||
|
{ name: 'staging', color: 'orange', icon: '🚧' },
|
||||||
|
{ name: 'production', color: 'green', icon: '🚀' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 渲染环境标签
|
||||||
|
const renderEnvironmentTags = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-1 mb-3">
|
||||||
|
<IconCloud className="text-gray-400 text-xs mr-1" />
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{environments.map((env) => (
|
||||||
|
<Tooltip key={env.name} content={`${env.name} 环境`}>
|
||||||
|
<Tag
|
||||||
|
size="small"
|
||||||
|
color={env.color}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||||
|
>
|
||||||
|
<span className="mr-1">{env.icon}</span>
|
||||||
|
{env.name}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProjectClick = useCallback(() => {
|
||||||
|
navigate(`/project/${project.id}`);
|
||||||
|
}, [navigate, project.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="foka-card !rounded-xl border border-gray-200 h-[280px] cursor-pointer"
|
||||||
|
hoverable
|
||||||
|
bodyStyle={{ padding: '20px' }}
|
||||||
|
onClick={onProjectClick}
|
||||||
|
>
|
||||||
|
{/* 项目头部 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Avatar
|
||||||
|
size={40}
|
||||||
|
className="bg-blue-600 text-white text-base font-semibold"
|
||||||
|
>
|
||||||
|
{project.name.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<div className="ml-3">
|
||||||
|
<Typography.Title
|
||||||
|
heading={5}
|
||||||
|
className="!m-0 !text-base !font-semibold"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</Typography.Title>
|
||||||
|
<Text type="secondary" className="text-xs">
|
||||||
|
更新于 2天前
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tag color="blue" size="small" className="font-medium">
|
||||||
|
活跃
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 项目描述 */}
|
||||||
|
<Paragraph className="!m-0 !mb-4 !text-gray-600 !text-sm !leading-6 h-[42px] overflow-hidden line-clamp-2">
|
||||||
|
{project.description || '暂无描述'}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{/* 环境信息 */}
|
||||||
|
{renderEnvironmentTags()}
|
||||||
|
|
||||||
|
{/* 项目信息 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-2 flex items-center">
|
||||||
|
<IconGitea className="mr-1.5 w-4 text-gray-500" />
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
className="text-xs truncate max-w-[200px]"
|
||||||
|
title={project.repository}
|
||||||
|
>
|
||||||
|
{project.repository}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Space size={16}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<IconBranch className="mr-1 text-gray-500 text-xs" />
|
||||||
|
<Text className="text-xs text-gray-500">main</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<IconCalendar className="mr-1 text-gray-500 text-xs" />
|
||||||
|
<Text className="text-xs text-gray-500">3个提交</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectCard;
|
||||||
69
apps/web/src/pages/project/list/index.tsx
Normal file
69
apps/web/src/pages/project/list/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Button, Grid, Message, Typography } from '@arco-design/web-react';
|
||||||
|
import { IconPlus } from '@arco-design/web-react/icon';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
import CreateProjectModal from './components/CreateProjectModal';
|
||||||
|
import ProjectCard from './components/ProjectCard';
|
||||||
|
import { projectService } from './service';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
function ProjectPage() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
const response = await projectService.list();
|
||||||
|
setProjects(response.data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateProject = () => {
|
||||||
|
setCreateModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSuccess = (newProject: Project) => {
|
||||||
|
setProjects((prev) => [newProject, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCancel = () => {
|
||||||
|
setCreateModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Typography.Title heading={2} className="!m-0 !text-gray-900">
|
||||||
|
我的项目
|
||||||
|
</Typography.Title>
|
||||||
|
<Text type="secondary">管理和查看您的所有项目</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconPlus />}
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
className="!rounded-lg"
|
||||||
|
>
|
||||||
|
新建项目
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Grid.Row gutter={[16, 16]}>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Grid.Col key={project.id} span={8}>
|
||||||
|
<ProjectCard project={project} />
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid.Row>
|
||||||
|
|
||||||
|
<CreateProjectModal
|
||||||
|
visible={createModalVisible}
|
||||||
|
onCancel={handleCreateCancel}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectPage;
|
||||||
72
apps/web/src/pages/project/list/service.ts
Normal file
72
apps/web/src/pages/project/list/service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { type APIResponse, net } from '@shared';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
|
||||||
|
class ProjectService {
|
||||||
|
async list(params?: ProjectQueryParams) {
|
||||||
|
const { data } = await net.request<APIResponse<ProjectListResponse>>({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/projects',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async show(id: string) {
|
||||||
|
const { data } = await net.request<APIResponse<Project>>({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/projects/${id}`,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(project: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
repository: string;
|
||||||
|
}) {
|
||||||
|
const { data } = await net.request<APIResponse<Project>>({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/projects',
|
||||||
|
data: project,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
project: Partial<{ name: string; description: string; repository: string }>,
|
||||||
|
) {
|
||||||
|
const { data } = await net.request<APIResponse<Project>>({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/projects/${id}`,
|
||||||
|
data: project,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
await net.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/projects/${id}`,
|
||||||
|
});
|
||||||
|
// DELETE 成功返回 204,无内容
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectService = new ProjectService();
|
||||||
|
|
||||||
|
interface ProjectListResponse {
|
||||||
|
data: Project[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectQueryParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
132
apps/web/src/pages/project/types.ts
Normal file
132
apps/web/src/pages/project/types.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
enum BuildStatus {
|
||||||
|
Idle = 'Pending',
|
||||||
|
Running = 'Running',
|
||||||
|
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 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
repository: string;
|
||||||
|
projectDir: string; // 项目工作目录路径(必填)
|
||||||
|
valid: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
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项目
|
||||||
|
}
|
||||||
1
apps/web/src/shared/index.ts
Normal file
1
apps/web/src/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './request';
|
||||||
62
apps/web/src/shared/request.ts
Normal file
62
apps/web/src/shared/request.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import axios, { type Axios, type AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
class Net {
|
||||||
|
private readonly instance: Axios;
|
||||||
|
constructor() {
|
||||||
|
this.instance = axios.create({
|
||||||
|
baseURL: process.env.BASE_URL,
|
||||||
|
timeout: 20000,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.applyInterceptors(this.instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyInterceptors(instance: Axios) {
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(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') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIResponse<T> {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const net = new Net();
|
||||||
28
apps/web/src/stores/global.tsx
Normal file
28
apps/web/src/stores/global.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { type APIResponse, net } from '@shared';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar_url: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalStore {
|
||||||
|
user: User | null;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGlobalStore = create<GlobalStore>((set) => ({
|
||||||
|
user: null,
|
||||||
|
setUser: (user: User) => set({ user }),
|
||||||
|
async refreshUser() {
|
||||||
|
const { data } = await net.request<APIResponse<User>>({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/info',
|
||||||
|
});
|
||||||
|
set({ user: data });
|
||||||
|
},
|
||||||
|
}));
|
||||||
1
apps/web/src/styles/index.css
Normal file
1
apps/web/src/styles/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
31
apps/web/src/utils/time.ts
Normal file
31
apps/web/src/utils/time.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -20,9 +20,11 @@
|
|||||||
"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/*"],
|
||||||
|
"@shared": ["./src/shared"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
31
package.json
31
package.json
@@ -1,32 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "foka-ci",
|
"name": "ark-ci",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"description": "",
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rsbuild build",
|
"dev": "pnpm --parallel -r run dev"
|
||||||
"check": "biome check --write",
|
|
||||||
"dev": "rsbuild dev --open",
|
|
||||||
"format": "biome format --write",
|
|
||||||
"preview": "rsbuild preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@arco-design/web-react": "^2.66.4",
|
|
||||||
"react": "^19.1.1",
|
|
||||||
"react-dom": "^19.1.1",
|
|
||||||
"react-router": "^7.8.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@arco-plugins/unplugin-react": "2.0.0-beta.5",
|
"@biomejs/biome": "2.0.6"
|
||||||
"@biomejs/biome": "2.0.6",
|
|
||||||
"@rsbuild/core": "^1.4.13",
|
|
||||||
"@rsbuild/plugin-less": "^1.4.0",
|
|
||||||
"@rsbuild/plugin-react": "^1.3.4",
|
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
|
||||||
"@types/react": "^19.1.9",
|
|
||||||
"@types/react-dom": "^19.1.7",
|
|
||||||
"tailwindcss": "^4.1.11",
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
},
|
},
|
||||||
|
"keywords": ["ci", "ark", "ark-ci"],
|
||||||
|
"author": "hurole",
|
||||||
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321"
|
"packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321"
|
||||||
}
|
}
|
||||||
|
|||||||
4624
pnpm-lock.yaml
generated
4624
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- 'apps/*'
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { ArcoDesignPlugin } from "@arco-plugins/unplugin-react";
|
|
||||||
import { defineConfig } from "@rsbuild/core";
|
|
||||||
import { pluginReact } from "@rsbuild/plugin-react";
|
|
||||||
import { pluginLess } from "@rsbuild/plugin-less";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [pluginReact(), pluginLess()],
|
|
||||||
tools: {
|
|
||||||
rspack: {
|
|
||||||
plugins: [
|
|
||||||
new ArcoDesignPlugin({
|
|
||||||
defaultLanguage: "zh-CN",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Route, Routes } from 'react-router';
|
|
||||||
import '@styles/index.css';
|
|
||||||
import Home from '@pages/home';
|
|
||||||
import Login from '@pages/login';
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route index element={<Home />} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
function Home() {
|
|
||||||
return <div>Home</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Input, Space } from '@arco-design/web-react';
|
|
||||||
import { IconUser, IconInfoCircle } from '@arco-design/web-react/icon';
|
|
||||||
function Login() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Space direction='vertical'>
|
|
||||||
<Input placeholder="username" prefix={<IconUser />} size="large" />
|
|
||||||
<Input.Password
|
|
||||||
placeholder="password"
|
|
||||||
prefix={<IconInfoCircle />}
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Login;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@import 'tailwindcss';
|
|
||||||
Reference in New Issue
Block a user