Compare commits
10 Commits
ad91a2f54d
...
ef4fce6d42
| Author | SHA1 | Date | |
|---|---|---|---|
| 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` 进行初始化,性能更优
|
||||||
13
apps/server/app.ts
Normal file
13
apps/server/app.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Koa from 'koa';
|
||||||
|
import { initMiddlewares } from './middlewares/index.ts';
|
||||||
|
import { log } from './libs/logger.ts';
|
||||||
|
|
||||||
|
const app = new Koa();
|
||||||
|
|
||||||
|
initMiddlewares(app);
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
log.info('APP', 'Server started at port %d', PORT);
|
||||||
|
});
|
||||||
87
apps/server/controllers/auth/index.ts
Normal file
87
apps/server/controllers/auth/index.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Context } from 'koa';
|
||||||
|
import { Controller, Get, Post } from '../../decorators/route.ts';
|
||||||
|
import prisma from '../../libs/db.ts';
|
||||||
|
import { log } from '../../libs/logger.ts';
|
||||||
|
import { gitea } from '../../libs/gitea.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 } = ctx.request.body as LoginRequestBody;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginRequestBody {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
4
apps/server/controllers/index.ts
Normal file
4
apps/server/controllers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// 控制器统一导出
|
||||||
|
export { ProjectController } from './project/index.ts';
|
||||||
|
export { UserController } from './user/index.ts';
|
||||||
|
export { AuthController } from './auth/index.ts';
|
||||||
167
apps/server/controllers/project/index.ts
Normal file
167
apps/server/controllers/project/index.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type { Context } from 'koa';
|
||||||
|
import prisma from '../../libs/db.ts';
|
||||||
|
import { log } from '../../libs/logger.ts';
|
||||||
|
import { BusinessError } from '../../middlewares/exception.ts';
|
||||||
|
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
|
||||||
|
import {
|
||||||
|
createProjectSchema,
|
||||||
|
updateProjectSchema,
|
||||||
|
listProjectQuerySchema,
|
||||||
|
projectIdSchema,
|
||||||
|
} from './schema.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/projects - 创建项目
|
||||||
|
@Post('')
|
||||||
|
async create(ctx: Context) {
|
||||||
|
const validatedData = createProjectSchema.parse(ctx.request.body);
|
||||||
|
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
name: validatedData.name,
|
||||||
|
description: validatedData.description || '',
|
||||||
|
repository: validatedData.repository,
|
||||||
|
createdBy: 'system',
|
||||||
|
updatedBy: 'system',
|
||||||
|
valid: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('project', 'Created new project: %s', project.name);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/server/controllers/project/schema.ts
Normal file
57
apps/server/controllers/project/schema.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建项目验证架构
|
||||||
|
*/
|
||||||
|
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: '仓库地址不能为空' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新项目验证架构
|
||||||
|
*/
|
||||||
|
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>;
|
||||||
117
apps/server/controllers/user/index.ts
Normal file
117
apps/server/controllers/user/index.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Context } from 'koa';
|
||||||
|
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
|
||||||
|
import { BusinessError } from '../../middlewares/exception.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 } = ctx.params;
|
||||||
|
|
||||||
|
// 模拟根据ID查找用户
|
||||||
|
const user = {
|
||||||
|
id: Number(id),
|
||||||
|
name: 'User ' + id,
|
||||||
|
email: `user${id}@example.com`,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Number(id) > 100) {
|
||||||
|
throw new BusinessError('用户不存在', 2001, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('')
|
||||||
|
async create(ctx: Context) {
|
||||||
|
const body = (ctx.request as any).body;
|
||||||
|
|
||||||
|
// 模拟创建用户
|
||||||
|
const newUser = {
|
||||||
|
id: Date.now(),
|
||||||
|
...body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
status: 'active'
|
||||||
|
};
|
||||||
|
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('/:id')
|
||||||
|
async update(ctx: Context) {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const body = (ctx.request as any).body;
|
||||||
|
|
||||||
|
// 模拟更新用户
|
||||||
|
const updatedUser = {
|
||||||
|
id: Number(id),
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('/:id')
|
||||||
|
async delete(ctx: Context) {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
if (Number(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 } = 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(String(keyword).toLowerCase()) ||
|
||||||
|
user.email.toLowerCase().includes(String(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) || '';
|
||||||
|
}
|
||||||
7
apps/server/libs/db.ts
Normal file
7
apps/server/libs/db.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { PrismaClient } from '../generated/prisma/index.js'
|
||||||
|
|
||||||
|
const prismaClientSingleton = () => {
|
||||||
|
return new PrismaClient();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default prismaClientSingleton();
|
||||||
94
apps/server/libs/gitea.ts
Normal file
94
apps/server/libs/gitea.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
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`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/server/middlewares/router.ts
Normal file
70
apps/server/middlewares/router.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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,
|
||||||
|
} 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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 输出注册的路由信息
|
||||||
|
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;
|
||||||
|
}
|
||||||
35
apps/server/package.json
Normal file
35
apps/server/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"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/client": "^6.15.0",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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": "^6.15.0",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/server/prisma/data/dev.db
Normal file
BIN
apps/server/prisma/data/dev.db
Normal file
Binary file not shown.
49
apps/server/prisma/schema.prisma
Normal file
49
apps/server/prisma/schema.prisma
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
repository String
|
||||||
|
valid Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdBy String
|
||||||
|
updatedBy String
|
||||||
|
}
|
||||||
|
|
||||||
|
model Environment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
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")
|
||||||
|
}
|
||||||
10
apps/server/tsconfig.json
Normal file
10
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"@tsconfig/node22/tsconfig.json",
|
||||||
|
"@tsconfig/node-ts/tsconfig.json"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": false
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/web/.env
Normal file
1
apps/web/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
BASE_URL=http://192.168.1.36:3001
|
||||||
37
apps/web/package.json
Normal file
37
apps/web/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"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.4",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router": "^7.8.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@arco-plugins/unplugin-react": "2.0.0-beta.5",
|
||||||
|
"@rsbuild/core": "^1.4.13",
|
||||||
|
"@rsbuild/plugin-less": "^1.4.0",
|
||||||
|
"@rsbuild/plugin-react": "^1.3.4",
|
||||||
|
"@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 { pluginReact } from '@rsbuild/plugin-react';
|
||||||
|
import { pluginLess } from '@rsbuild/plugin-less';
|
||||||
|
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
10
apps/web/src/hooks/useAsyncEffect.ts
Normal file
10
apps/web/src/hooks/useAsyncEffect.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useAsyncEffect(
|
||||||
|
effect: () => Promise<void>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
effect();
|
||||||
|
}, [...deps]);
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from '@pages/App';
|
import App from '@pages/App';
|
||||||
import { BrowserRouter } from 'react-router';
|
import { BrowserRouter } from 'react-router';
|
||||||
|
import { useGlobalStore } from './stores/global';
|
||||||
|
|
||||||
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 { Route, Routes, Navigate } from 'react-router';
|
||||||
|
import Home from '@pages/home';
|
||||||
|
import Login from '@pages/login';
|
||||||
|
import ProjectList from '@pages/project/list';
|
||||||
|
import ProjectDetail from '@pages/project/detail';
|
||||||
|
import Env from '@pages/env';
|
||||||
|
|
||||||
|
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;
|
||||||
12
apps/web/src/pages/env/index.tsx
vendored
Normal file
12
apps/web/src/pages/env/index.tsx
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function Env() {
|
||||||
|
const [env, setEnv] = useState([]);
|
||||||
|
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 { useState } from 'react';
|
||||||
|
import Logo from '@assets/images/logo.svg?react';
|
||||||
|
import { Link, Outlet } from 'react-router';
|
||||||
|
import { useGlobalStore } from '../../stores/global';
|
||||||
|
import { loginService } from '@pages/login/service';
|
||||||
|
|
||||||
|
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 { loginService } from './service';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
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 { net } from '@shared';
|
||||||
|
import type { AuthURLResponse, AuthLoginResponse } from './types';
|
||||||
|
import type { NavigateFunction } from 'react-router';
|
||||||
|
import { Message, Notification } from '@arco-design/web-react';
|
||||||
|
import { useGlobalStore } from '../../stores/global';
|
||||||
|
|
||||||
|
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>;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { List, Tag, Space } from '@arco-design/web-react';
|
||||||
|
|
||||||
|
// 部署记录类型定义
|
||||||
|
interface DeployRecord {
|
||||||
|
id: number;
|
||||||
|
branch: string;
|
||||||
|
env: string;
|
||||||
|
commit: string;
|
||||||
|
status: 'success' | 'running' | 'failed' | 'pending';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeployRecordItemProps {
|
||||||
|
item: DeployRecord;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeployRecordItem({
|
||||||
|
item,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: DeployRecordItemProps) {
|
||||||
|
// 状态标签渲染函数
|
||||||
|
const getStatusTag = (status: DeployRecord['status']) => {
|
||||||
|
const statusMap: Record<
|
||||||
|
DeployRecord['status'],
|
||||||
|
{ 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.commit}
|
||||||
|
</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)}
|
||||||
|
</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">
|
||||||
|
{item.createdAt}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeployRecordItem;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Typography, Tag, Switch, Button } from '@arco-design/web-react';
|
||||||
|
import { IconDragArrow, IconEdit, IconDelete } from '@arco-design/web-react/icon';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
// 流水线步骤类型定义
|
||||||
|
interface PipelineStep {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
script: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineStepItemProps {
|
||||||
|
step: PipelineStep;
|
||||||
|
index: number;
|
||||||
|
pipelineId: string;
|
||||||
|
onToggle: (pipelineId: string, stepId: string, enabled: boolean) => void;
|
||||||
|
onEdit: (pipelineId: string, step: PipelineStep) => void;
|
||||||
|
onDelete: (pipelineId: string, stepId: string) => 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>
|
||||||
|
<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;
|
||||||
854
apps/web/src/pages/project/detail/index.tsx
Normal file
854
apps/web/src/pages/project/detail/index.tsx
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Tabs,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Input,
|
||||||
|
Card,
|
||||||
|
Switch,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Message,
|
||||||
|
Collapse,
|
||||||
|
Dropdown,
|
||||||
|
Menu,
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
|
||||||
|
import { detailService } from './service';
|
||||||
|
import {
|
||||||
|
IconPlayArrow,
|
||||||
|
IconPlus,
|
||||||
|
IconEdit,
|
||||||
|
IconDelete,
|
||||||
|
IconMore,
|
||||||
|
IconCopy,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
import DeployRecordItem from './components/DeployRecordItem';
|
||||||
|
import PipelineStepItem from './components/PipelineStepItem';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
arrayMove,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
|
||||||
|
// 部署记录类型定义
|
||||||
|
interface DeployRecord {
|
||||||
|
id: number;
|
||||||
|
branch: string;
|
||||||
|
env: string;
|
||||||
|
commit: string;
|
||||||
|
status: 'success' | 'running' | 'failed' | 'pending';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流水线步骤类型定义
|
||||||
|
interface PipelineStep {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
script: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流水线类型定义
|
||||||
|
interface Pipeline {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
steps: PipelineStep[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectDetailPage() {
|
||||||
|
const [detail, setDetail] = useState<Project | null>();
|
||||||
|
|
||||||
|
// 拖拽传感器配置
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const [selectedRecordId, setSelectedRecordId] = useState<number>(1);
|
||||||
|
const [pipelines, setPipelines] = useState<Pipeline[]>([
|
||||||
|
{
|
||||||
|
id: 'pipeline1',
|
||||||
|
name: '前端部署流水线',
|
||||||
|
description: '用于前端项目的构建和部署',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2024-09-07 10:00:00',
|
||||||
|
updatedAt: '2024-09-07 14:30:00',
|
||||||
|
steps: [
|
||||||
|
{ id: 'step1', name: '安装依赖', script: 'npm install', enabled: true },
|
||||||
|
{ id: 'step2', name: '运行测试', script: 'npm test', enabled: true },
|
||||||
|
{
|
||||||
|
id: 'step3',
|
||||||
|
name: '构建项目',
|
||||||
|
script: 'npm run build',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pipeline2',
|
||||||
|
name: 'Docker部署流水线',
|
||||||
|
description: '用于容器化部署的流水线',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2024-09-06 16:20:00',
|
||||||
|
updatedAt: '2024-09-07 09:15:00',
|
||||||
|
steps: [
|
||||||
|
{ id: 'step1', name: '安装依赖', script: 'npm install', enabled: true },
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: '构建镜像',
|
||||||
|
script: 'docker build -t $PROJECT_NAME:$BUILD_NUMBER .',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step3',
|
||||||
|
name: 'K8s部署',
|
||||||
|
script: 'kubectl apply -f deployment.yaml',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [selectedPipelineId, setSelectedPipelineId] = useState<string>(
|
||||||
|
pipelines.length > 0 ? pipelines[0].id : '',
|
||||||
|
);
|
||||||
|
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
|
||||||
|
const [editingPipelineId, setEditingPipelineId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [pipelineModalVisible, setPipelineModalVisible] = useState(false);
|
||||||
|
const [editingPipeline, setEditingPipeline] = useState<Pipeline | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [pipelineForm] = Form.useForm();
|
||||||
|
const [deployRecords, setDeployRecords] = useState<DeployRecord[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
branch: 'main',
|
||||||
|
env: 'development',
|
||||||
|
commit: '1d1224ae1',
|
||||||
|
status: 'success',
|
||||||
|
createdAt: '2024-09-07 14:30:25',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
branch: 'develop',
|
||||||
|
env: 'staging',
|
||||||
|
commit: '2f4b5c8e9',
|
||||||
|
status: 'running',
|
||||||
|
createdAt: '2024-09-07 13:45:12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
branch: 'feature/user-auth',
|
||||||
|
env: 'development',
|
||||||
|
commit: '3a7d9f2b1',
|
||||||
|
status: 'failed',
|
||||||
|
createdAt: '2024-09-07 12:20:45',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
branch: 'main',
|
||||||
|
env: 'production',
|
||||||
|
commit: '4e8b6a5c3',
|
||||||
|
status: 'success',
|
||||||
|
createdAt: '2024-09-07 10:15:30',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { id } = useParams();
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
if (id) {
|
||||||
|
const project = await detailService.getProject(id);
|
||||||
|
setDetail(project);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取模拟的构建日志
|
||||||
|
const getBuildLogs = (recordId: number): string[] => {
|
||||||
|
const logs: Record<number, string[]> = {
|
||||||
|
1: [
|
||||||
|
'[2024-09-07 14:30:25] 开始构建...',
|
||||||
|
'[2024-09-07 14:30:26] 拉取代码: git clone https://github.com/user/repo.git',
|
||||||
|
'[2024-09-07 14:30:28] 切换分支: git checkout main',
|
||||||
|
'[2024-09-07 14:30:29] 安装依赖: npm install',
|
||||||
|
'[2024-09-07 14:31:15] 运行测试: npm test',
|
||||||
|
'[2024-09-07 14:31:30] ✅ 所有测试通过',
|
||||||
|
'[2024-09-07 14:31:31] 构建项目: npm run build',
|
||||||
|
'[2024-09-07 14:32:10] 构建镜像: docker build -t app:latest .',
|
||||||
|
'[2024-09-07 14:33:25] 推送镜像: docker push registry.com/app:latest',
|
||||||
|
'[2024-09-07 14:34:10] 部署到开发环境...',
|
||||||
|
'[2024-09-07 14:34:45] ✅ 部署成功',
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
'[2024-09-07 13:45:12] 开始构建...',
|
||||||
|
'[2024-09-07 13:45:13] 拉取代码: git clone https://github.com/user/repo.git',
|
||||||
|
'[2024-09-07 13:45:15] 切换分支: git checkout develop',
|
||||||
|
'[2024-09-07 13:45:16] 安装依赖: npm install',
|
||||||
|
'[2024-09-07 13:46:02] 运行测试: npm test',
|
||||||
|
'[2024-09-07 13:46:18] ✅ 所有测试通过',
|
||||||
|
'[2024-09-07 13:46:19] 构建项目: npm run build',
|
||||||
|
'[2024-09-07 13:47:05] 构建镜像: docker build -t app:develop .',
|
||||||
|
'[2024-09-07 13:48:20] 🔄 正在推送镜像...',
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
'[2024-09-07 12:20:45] 开始构建...',
|
||||||
|
'[2024-09-07 12:20:46] 拉取代码: git clone https://github.com/user/repo.git',
|
||||||
|
'[2024-09-07 12:20:48] 切换分支: git checkout feature/user-auth',
|
||||||
|
'[2024-09-07 12:20:49] 安装依赖: npm install',
|
||||||
|
'[2024-09-07 12:21:35] 运行测试: npm test',
|
||||||
|
'[2024-09-07 12:21:50] ❌ 测试失败',
|
||||||
|
'[2024-09-07 12:21:51] Error: Authentication test failed',
|
||||||
|
'[2024-09-07 12:21:51] ❌ 构建失败',
|
||||||
|
],
|
||||||
|
4: [
|
||||||
|
'[2024-09-07 10:15:30] 开始构建...',
|
||||||
|
'[2024-09-07 10:15:31] 拉取代码: git clone https://github.com/user/repo.git',
|
||||||
|
'[2024-09-07 10:15:33] 切换分支: git checkout main',
|
||||||
|
'[2024-09-07 10:15:34] 安装依赖: npm install',
|
||||||
|
'[2024-09-07 10:16:20] 运行测试: npm test',
|
||||||
|
'[2024-09-07 10:16:35] ✅ 所有测试通过',
|
||||||
|
'[2024-09-07 10:16:36] 构建项目: npm run build',
|
||||||
|
'[2024-09-07 10:17:22] 构建镜像: docker build -t app:v1.0.0 .',
|
||||||
|
'[2024-09-07 10:18:45] 推送镜像: docker push registry.com/app:v1.0.0',
|
||||||
|
'[2024-09-07 10:19:30] 部署到生产环境...',
|
||||||
|
'[2024-09-07 10:20:15] ✅ 部署成功',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return logs[recordId] || ['暂无日志记录'];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加新流水线
|
||||||
|
const handleAddPipeline = () => {
|
||||||
|
setEditingPipeline(null);
|
||||||
|
pipelineForm.resetFields();
|
||||||
|
setPipelineModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑流水线
|
||||||
|
const handleEditPipeline = (pipeline: Pipeline) => {
|
||||||
|
setEditingPipeline(pipeline);
|
||||||
|
pipelineForm.setFieldsValue({
|
||||||
|
name: pipeline.name,
|
||||||
|
description: pipeline.description,
|
||||||
|
});
|
||||||
|
setPipelineModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除流水线
|
||||||
|
const handleDeletePipeline = (pipelineId: string) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content:
|
||||||
|
'确定要删除这个流水线吗?此操作不可撤销,将同时删除该流水线下的所有步骤。',
|
||||||
|
onOk: () => {
|
||||||
|
setPipelines((prev) => {
|
||||||
|
const newPipelines = prev.filter((pipeline) => pipeline.id !== pipelineId);
|
||||||
|
// 如果删除的是当前选中的流水线,选中第一个或清空选择
|
||||||
|
if (selectedPipelineId === pipelineId) {
|
||||||
|
setSelectedPipelineId(newPipelines.length > 0 ? newPipelines[0].id : '');
|
||||||
|
}
|
||||||
|
return newPipelines;
|
||||||
|
});
|
||||||
|
Message.success('流水线删除成功');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制流水线
|
||||||
|
const handleCopyPipeline = (pipeline: Pipeline) => {
|
||||||
|
const newPipeline: Pipeline = {
|
||||||
|
...pipeline,
|
||||||
|
id: `pipeline_${Date.now()}`,
|
||||||
|
name: `${pipeline.name} - 副本`,
|
||||||
|
createdAt: new Date().toLocaleString(),
|
||||||
|
updatedAt: new Date().toLocaleString(),
|
||||||
|
steps: pipeline.steps.map((step) => ({
|
||||||
|
...step,
|
||||||
|
id: `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
setPipelines((prev) => [...prev, newPipeline]);
|
||||||
|
// 自动选中新复制的流水线
|
||||||
|
setSelectedPipelineId(newPipeline.id);
|
||||||
|
Message.success('流水线复制成功');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换流水线启用状态
|
||||||
|
const handleTogglePipeline = (pipelineId: string, enabled: boolean) => {
|
||||||
|
setPipelines((prev) =>
|
||||||
|
prev.map((pipeline) =>
|
||||||
|
pipeline.id === pipelineId ? { ...pipeline, enabled } : pipeline,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存流水线
|
||||||
|
const handleSavePipeline = async () => {
|
||||||
|
try {
|
||||||
|
const values = await pipelineForm.validate();
|
||||||
|
if (editingPipeline) {
|
||||||
|
setPipelines((prev) => [
|
||||||
|
...prev.map((pipeline) =>
|
||||||
|
pipeline.id === editingPipeline.id
|
||||||
|
? {
|
||||||
|
...pipeline,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
updatedAt: new Date().toLocaleString(),
|
||||||
|
}
|
||||||
|
: pipeline,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
Message.success('流水线更新成功');
|
||||||
|
} else {
|
||||||
|
const newPipeline: Pipeline = {
|
||||||
|
id: `pipeline_${Date.now()}`,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
enabled: true,
|
||||||
|
steps: [],
|
||||||
|
createdAt: new Date().toLocaleString(),
|
||||||
|
updatedAt: new Date().toLocaleString(),
|
||||||
|
};
|
||||||
|
setPipelines((prev) => [...prev, newPipeline]);
|
||||||
|
// 自动选中新创建的流水线
|
||||||
|
setSelectedPipelineId(newPipeline.id);
|
||||||
|
Message.success('流水线创建成功');
|
||||||
|
}
|
||||||
|
setPipelineModalVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('表单验证失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加新步骤
|
||||||
|
const handleAddStep = (pipelineId: string) => {
|
||||||
|
setEditingStep(null);
|
||||||
|
setEditingPipelineId(pipelineId);
|
||||||
|
form.resetFields();
|
||||||
|
setEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑步骤
|
||||||
|
const handleEditStep = (pipelineId: string, step: PipelineStep) => {
|
||||||
|
setEditingStep(step);
|
||||||
|
setEditingPipelineId(pipelineId);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: step.name,
|
||||||
|
script: step.script,
|
||||||
|
});
|
||||||
|
setEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除步骤
|
||||||
|
const handleDeleteStep = (pipelineId: string, stepId: string) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '确定要删除这个流水线步骤吗?此操作不可撤销。',
|
||||||
|
onOk: () => {
|
||||||
|
setPipelines((prev) =>
|
||||||
|
prev.map((pipeline) =>
|
||||||
|
pipeline.id === pipelineId
|
||||||
|
? {
|
||||||
|
...pipeline,
|
||||||
|
steps: pipeline.steps.filter((step) => step.id !== stepId),
|
||||||
|
}
|
||||||
|
: pipeline,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Message.success('步骤删除成功');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换步骤启用状态
|
||||||
|
const handleToggleStep = (
|
||||||
|
pipelineId: string,
|
||||||
|
stepId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) => {
|
||||||
|
setPipelines((prev) =>
|
||||||
|
prev.map((pipeline) =>
|
||||||
|
pipeline.id === pipelineId
|
||||||
|
? {
|
||||||
|
...pipeline,
|
||||||
|
steps: pipeline.steps.map((step) =>
|
||||||
|
step.id === stepId ? { ...step, enabled } : step,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: pipeline,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖拽结束处理
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPipelineId) {
|
||||||
|
setPipelines((prev) =>
|
||||||
|
prev.map((pipeline) => {
|
||||||
|
if (pipeline.id === selectedPipelineId) {
|
||||||
|
const oldIndex = pipeline.steps.findIndex((step) => step.id === active.id);
|
||||||
|
const newIndex = pipeline.steps.findIndex((step) => step.id === over.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pipeline,
|
||||||
|
steps: arrayMove(pipeline.steps, oldIndex, newIndex),
|
||||||
|
updatedAt: new Date().toLocaleString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return pipeline;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
Message.success('步骤顺序调整成功');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存步骤
|
||||||
|
const handleSaveStep = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validate();
|
||||||
|
if (editingStep && editingPipelineId) {
|
||||||
|
setPipelines((prev) =>
|
||||||
|
prev.map((pipeline) =>
|
||||||
|
pipeline.id === editingPipelineId
|
||||||
|
? {
|
||||||
|
...pipeline,
|
||||||
|
steps: pipeline.steps.map((step) =>
|
||||||
|
step.id === editingStep.id
|
||||||
|
? { ...step, name: values.name, script: values.script }
|
||||||
|
: step,
|
||||||
|
),
|
||||||
|
updatedAt: new Date().toLocaleString(),
|
||||||
|
}
|
||||||
|
: pipeline,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Message.success('步骤更新成功');
|
||||||
|
} else if (editingPipelineId) {
|
||||||
|
const newStep: PipelineStep = {
|
||||||
|
id: `step_${Date.now()}`,
|
||||||
|
name: values.name,
|
||||||
|
script: values.script,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
setPipelines((prev) =>
|
||||||
|
prev.map((pipeline) =>
|
||||||
|
pipeline.id === editingPipelineId
|
||||||
|
? {
|
||||||
|
...pipeline,
|
||||||
|
steps: [...pipeline.steps, newStep],
|
||||||
|
updatedAt: new Date().toLocaleString(),
|
||||||
|
}
|
||||||
|
: pipeline,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Message.success('步骤添加成功');
|
||||||
|
}
|
||||||
|
setEditModalVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('表单验证失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRecord = deployRecords.find(
|
||||||
|
(record) => record.id === selectedRecordId,
|
||||||
|
);
|
||||||
|
const buildLogs = getBuildLogs(selectedRecordId);
|
||||||
|
|
||||||
|
// 简单的状态标签渲染函数(仅用于构建日志区域)
|
||||||
|
const renderStatusTag = (status: DeployRecord['status']) => {
|
||||||
|
const statusMap: Record<
|
||||||
|
DeployRecord['status'],
|
||||||
|
{ 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 renderDeployRecordItem = (item: DeployRecord, index: number) => {
|
||||||
|
const isSelected = item.id === selectedRecordId;
|
||||||
|
return (
|
||||||
|
<DeployRecordItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelect={setSelectedRecordId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 flex flex-col h-full">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Typography.Title heading={2} className="!m-0 !text-gray-900">
|
||||||
|
{detail?.name}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">自动化地部署项目</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" icon={<IconPlayArrow />}>
|
||||||
|
部署
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-md flex-1">
|
||||||
|
<Tabs type="line" size="large">
|
||||||
|
<Tabs.TabPane title="部署记录" key="deployRecords">
|
||||||
|
<div className="grid grid-cols-5 gap-6 h-full">
|
||||||
|
{/* 左侧部署记录列表 */}
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
共 {deployRecords.length} 条部署记录
|
||||||
|
</Typography.Text>
|
||||||
|
<Button size="small" type="outline">
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<List
|
||||||
|
className="bg-white rounded-lg border"
|
||||||
|
dataSource={deployRecords}
|
||||||
|
render={renderDeployRecordItem}
|
||||||
|
split={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧构建日志 */}
|
||||||
|
<div className="col-span-3 bg-white rounded-lg border h-full overflow-hidden">
|
||||||
|
<div className="p-4 border-b bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Typography.Title heading={5} className="!m-0">
|
||||||
|
构建日志 #{selectedRecordId}
|
||||||
|
</Typography.Title>
|
||||||
|
{selectedRecord && (
|
||||||
|
<Typography.Text type="secondary" className="text-sm">
|
||||||
|
{selectedRecord.branch} · {selectedRecord.env} ·{' '}
|
||||||
|
{selectedRecord.createdAt}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedRecord && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{renderStatusTag(selectedRecord.status)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 h-full overflow-y-auto">
|
||||||
|
<div className="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm h-full overflow-y-auto">
|
||||||
|
{buildLogs.map((log: string, index: number) => (
|
||||||
|
<div
|
||||||
|
key={`${selectedRecordId}-${log.slice(0, 30)}-${index}`}
|
||||||
|
className="mb-1 leading-relaxed"
|
||||||
|
>
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane title="流水线" key="pipeline">
|
||||||
|
<div className="grid grid-cols-5 gap-6 h-full">
|
||||||
|
{/* 左侧流水线列表 */}
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
共 {pipelines.length} 条流水线
|
||||||
|
</Typography.Text>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconPlus />}
|
||||||
|
size="small"
|
||||||
|
onClick={handleAddPipeline}
|
||||||
|
>
|
||||||
|
新建流水线
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pipelines.map((pipeline) => {
|
||||||
|
const isSelected = pipeline.id === selectedPipelineId;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={pipeline.id}
|
||||||
|
className={`cursor-pointer transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-50 border-l-4 border-blue-500 border-blue-300'
|
||||||
|
: 'hover:bg-gray-50 border-gray-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedPipelineId(pipeline.id)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Typography.Title
|
||||||
|
heading={6}
|
||||||
|
className={`!m-0 ${
|
||||||
|
isSelected ? 'text-blue-600' : 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pipeline.name}
|
||||||
|
</Typography.Title>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={pipeline.enabled}
|
||||||
|
onChange={(enabled, e) => {
|
||||||
|
// 阻止事件冒泡
|
||||||
|
e?.stopPropagation?.();
|
||||||
|
handleTogglePipeline(pipeline.id, enabled);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{!pipeline.enabled && (
|
||||||
|
<Tag color="gray" size="small">
|
||||||
|
已禁用
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
droplist={
|
||||||
|
<Menu>
|
||||||
|
<Menu.Item
|
||||||
|
key="edit"
|
||||||
|
onClick={() => handleEditPipeline(pipeline)}
|
||||||
|
>
|
||||||
|
<IconEdit className="mr-2" />
|
||||||
|
编辑流水线
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
key="copy"
|
||||||
|
onClick={() => handleCopyPipeline(pipeline)}
|
||||||
|
>
|
||||||
|
<IconCopy className="mr-2" />
|
||||||
|
复制流水线
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
key="delete"
|
||||||
|
onClick={() =>
|
||||||
|
handleDeletePipeline(pipeline.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconDelete className="mr-2" />
|
||||||
|
删除流水线
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<IconMore />}
|
||||||
|
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded-md p-1 transition-all duration-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<div>{pipeline.description}</div>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span>共 {pipeline.steps.length} 个步骤</span>
|
||||||
|
<span>{pipeline.updatedAt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{pipelines.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
暂无流水线,点击上方"新建流水线"按钮开始创建
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧流水线步骤详情 */}
|
||||||
|
<div className="col-span-3 bg-white rounded-lg border h-full overflow-hidden">
|
||||||
|
{selectedPipelineId && pipelines.find(p => p.id === selectedPipelineId) ? (
|
||||||
|
(() => {
|
||||||
|
const selectedPipeline = pipelines.find(p => p.id === selectedPipelineId)!;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Typography.Title heading={5} className="!m-0">
|
||||||
|
{selectedPipeline.name} - 流水线步骤
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary" className="text-sm">
|
||||||
|
{selectedPipeline.description} · 共 {selectedPipeline.steps.length} 个步骤
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconPlus />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleAddStep(selectedPipelineId)}
|
||||||
|
>
|
||||||
|
添加步骤
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 h-full overflow-y-auto">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={selectedPipeline.steps.map(step => step.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedPipeline.steps.map((step, index) => (
|
||||||
|
<PipelineStepItem
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
index={index}
|
||||||
|
pipelineId={selectedPipelineId}
|
||||||
|
onToggle={handleToggleStep}
|
||||||
|
onEdit={handleEditStep}
|
||||||
|
onDelete={handleDeleteStep}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectedPipeline.steps.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
暂无步骤,点击上方"添加步骤"按钮开始配置
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
请选择左侧的流水线查看详细步骤
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建/编辑流水线模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={editingPipeline ? '编辑流水线' : '新建流水线'}
|
||||||
|
visible={pipelineModalVisible}
|
||||||
|
onOk={handleSavePipeline}
|
||||||
|
onCancel={() => setPipelineModalVisible(false)}
|
||||||
|
style={{ width: 500 }}
|
||||||
|
>
|
||||||
|
<Form form={pipelineForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
field="name"
|
||||||
|
label="流水线名称"
|
||||||
|
rules={[{ required: true, message: '请输入流水线名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:前端部署流水线、Docker部署流水线..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
field="description"
|
||||||
|
label="流水线描述"
|
||||||
|
rules={[{ required: true, message: '请输入流水线描述' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="描述这个流水线的用途和特点..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 编辑步骤模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={editingStep ? '编辑流水线步骤' : '添加流水线步骤'}
|
||||||
|
visible={editModalVisible}
|
||||||
|
onOk={handleSaveStep}
|
||||||
|
onCancel={() => setEditModalVisible(false)}
|
||||||
|
style={{ width: 600 }}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
field="name"
|
||||||
|
label="步骤名称"
|
||||||
|
rules={[{ required: true, message: '请输入步骤名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:安装依赖、运行测试、构建项目..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
field="script"
|
||||||
|
label="Shell 脚本"
|
||||||
|
rules={[{ required: true, message: '请输入脚本内容' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="例如:npm install npm test npm run build"
|
||||||
|
rows={8}
|
||||||
|
style={{ fontFamily: 'Monaco, Consolas, monospace' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div className="bg-blue-50 p-3 rounded text-sm">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
<strong>可用环境变量:</strong>
|
||||||
|
<br />• $PROJECT_NAME - 项目名称
|
||||||
|
<br />• $BUILD_NUMBER - 构建编号
|
||||||
|
<br />• $REGISTRY - 镜像仓库地址
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectDetailPage;
|
||||||
13
apps/web/src/pages/project/detail/service.ts
Normal file
13
apps/web/src/pages/project/detail/service.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { net, type APIResponse } from '@shared';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
|
||||||
|
class DetailService {
|
||||||
|
async getProject(id: string) {
|
||||||
|
const { code, data } = await net.request<APIResponse<Project>>({
|
||||||
|
url: `/api/projects/${id}`,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const detailService = new DetailService();
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Modal, Form, Input, Button, Message } from '@arco-design/web-react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
import { projectService } from '../service';
|
||||||
|
|
||||||
|
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>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateProjectModal;
|
||||||
115
apps/web/src/pages/project/list/components/EditProjectModal.tsx
Normal file
115
apps/web/src/pages/project/list/components/EditProjectModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Modal, Form, Input, Button, Message } from '@arco-design/web-react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
import { projectService } from '../service';
|
||||||
|
|
||||||
|
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;
|
||||||
180
apps/web/src/pages/project/list/components/ProjectCard.tsx
Normal file
180
apps/web/src/pages/project/list/components/ProjectCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Tag,
|
||||||
|
Avatar,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
|
Dropdown,
|
||||||
|
Menu,
|
||||||
|
Modal,
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import {
|
||||||
|
IconBranch,
|
||||||
|
IconCalendar,
|
||||||
|
IconCloud,
|
||||||
|
IconEdit,
|
||||||
|
IconMore,
|
||||||
|
IconDelete,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
import type { Project } from '../../types';
|
||||||
|
import IconGitea from '@assets/images/gitea.svg?react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: Project;
|
||||||
|
onEdit?: (project: Project) => void;
|
||||||
|
onDelete?: (project: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// 处理删除操作
|
||||||
|
const handleDelete = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除项目',
|
||||||
|
content: `确定要删除项目 "${project.name}" 吗?此操作不可恢复。`,
|
||||||
|
okText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
okButtonProps: {
|
||||||
|
status: 'danger',
|
||||||
|
},
|
||||||
|
onOk: () => {
|
||||||
|
onDelete?.(project);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 获取环境信息
|
||||||
|
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>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Tag color="blue" size="small" className="font-medium">
|
||||||
|
活跃
|
||||||
|
</Tag>
|
||||||
|
<Dropdown
|
||||||
|
droplist={
|
||||||
|
<Menu>
|
||||||
|
<Menu.Item key="edit" onClick={() => onEdit?.(project)}>
|
||||||
|
<IconEdit className="mr-2" />
|
||||||
|
编辑
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
key="delete"
|
||||||
|
onClick={() => handleDelete()}
|
||||||
|
className="text-red-500"
|
||||||
|
>
|
||||||
|
<IconDelete className="mr-2" />
|
||||||
|
删除
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
}
|
||||||
|
position="br"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<IconMore />}
|
||||||
|
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50 transition-all duration-200 p-1 rounded-md"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 项目描述 */}
|
||||||
|
<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;
|
||||||
110
apps/web/src/pages/project/list/index.tsx
Normal file
110
apps/web/src/pages/project/list/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Grid, Typography, Button, Message } from '@arco-design/web-react';
|
||||||
|
import { IconPlus } from '@arco-design/web-react/icon';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
import { useAsyncEffect } from '../../../hooks/useAsyncEffect';
|
||||||
|
import { projectService } from './service';
|
||||||
|
import ProjectCard from './components/ProjectCard';
|
||||||
|
import EditProjectModal from './components/EditProjectModal';
|
||||||
|
import CreateProjectModal from './components/CreateProjectModal';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
function ProjectPage() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
const response = await projectService.list();
|
||||||
|
setProjects(response.data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditProject = (project: Project) => {
|
||||||
|
setEditingProject(project);
|
||||||
|
setEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSuccess = (updatedProject: Project) => {
|
||||||
|
setProjects(prev =>
|
||||||
|
prev.map(p => p.id === updatedProject.id ? updatedProject : p)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setEditModalVisible(false);
|
||||||
|
setEditingProject(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProject = () => {
|
||||||
|
setCreateModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSuccess = (newProject: Project) => {
|
||||||
|
setProjects(prev => [newProject, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCancel = () => {
|
||||||
|
setCreateModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async (project: Project) => {
|
||||||
|
try {
|
||||||
|
await projectService.delete(project.id);
|
||||||
|
setProjects(prev => prev.filter(p => p.id !== project.id));
|
||||||
|
Message.success('项目删除成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除项目失败:', error);
|
||||||
|
Message.error('删除项目失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
onEdit={handleEditProject}
|
||||||
|
onDelete={handleDeleteProject}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid.Row>
|
||||||
|
|
||||||
|
<EditProjectModal
|
||||||
|
visible={editModalVisible}
|
||||||
|
project={editingProject}
|
||||||
|
onCancel={handleEditCancel}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateProjectModal
|
||||||
|
visible={createModalVisible}
|
||||||
|
onCancel={handleCreateCancel}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectPage;
|
||||||
73
apps/web/src/pages/project/list/service.ts
Normal file
73
apps/web/src/pages/project/list/service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { net, type APIResponse } 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;
|
||||||
|
}
|
||||||
18
apps/web/src/pages/project/types.ts
Normal file
18
apps/web/src/pages/project/types.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
enum BuildStatus {
|
||||||
|
Idle = "Pending",
|
||||||
|
Running = "Running",
|
||||||
|
Stopped = "Stopped",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
repository: string;
|
||||||
|
valid: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
status: BuildStatus;
|
||||||
|
}
|
||||||
1
apps/web/src/shared/index.ts
Normal file
1
apps/web/src/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './request';
|
||||||
44
apps/web/src/shared/request.ts
Normal file
44
apps/web/src/shared/request.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import axios, { 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)
|
||||||
|
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> {
|
||||||
|
const { data } = await this.instance.request<T>(config);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { net, type APIResponse } 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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -22,7 +22,9 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
2995
pnpm-lock.yaml
generated
2995
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;
|
|
||||||
Reference in New Issue
Block a user