Compare commits

...

11 Commits

Author SHA1 Message Date
fd0cf782c4 feat: 增加 pipeline和 deployment 2025-09-21 21:38:42 +08:00
ef4fce6d42 feat: 重构项目页面架构并实现流水线步骤拖拽排序功能
主要更新:
- 重构项目页面结构:将原有项目页面拆分为 list 和 detail 两个子模块
- 新增项目详情页面,支持多标签页展示(流水线、部署记录)
- 实现流水线管理功能:支持新建、编辑、复制、删除、启用/禁用
- 实现流水线步骤管理:支持添加、编辑、删除、启用/禁用步骤
- 新增流水线步骤拖拽排序功能:集成 @dnd-kit 实现拖拽重排
- 优化左右两栏布局:左侧流水线列表,右侧步骤详情
- 新增部署记录展示功能:左右两栏布局,支持选中切换
- 提取可复用组件:DeployRecordItem、PipelineStepItem
- 添加表单验证和用户交互反馈
- 更新路由配置支持项目详情页面

技术改进:
- 安装 @dnd-kit 相关依赖实现拖拽功能
- 优化 TypeScript 类型定义
- 改进组件化设计,提高代码复用性
- 增强用户体验和交互反馈
2025-09-07 22:35:33 +08:00
f0e1a649ee feat: 修复退出登录登录信息未失效 2025-09-06 20:42:52 +08:00
cd99485c9a feat: 认证相关 2025-09-06 19:56:13 +08:00
5a25f350c7 feat: 项目的增删改查 2025-09-06 12:38:02 +08:00
9b54d18ef3 feat: project list 2025-09-06 01:44:33 +08:00
ef473d6084 feat: 完善项目架构和功能
- 修复路由配置,实现根路径自动重定向到/project
- 新增Gitea OAuth认证系统和相关组件
- 完善日志系统实现,包含pino日志工具和中间件
- 重构页面结构,分离项目管理和环境管理页面
- 新增CORS、Session等关键中间件
- 优化前端请求封装和类型定义
- 修复TypeScript类型错误和参数传递问题
2025-09-04 23:19:52 +08:00
d178df54da refactor: 迁移到TC39标准装饰器
- 将实验性装饰器重构为TC39 Stage 3标准装饰器
- 使用ClassMethodDecoratorContext和ClassDecoratorContext
- 采用addInitializer方法进行装饰器初始化
- 移除reflect-metadata依赖,使用WeakMap存储元数据
- 更新TypeScript配置为ES2021目标版本
- 简化tsconfig.json配置,移除useDefineForClassFields显式设置
- 创建TC39标准装饰器使用指南文档

技术优势:
- 符合ECMAScript官方标准提案
- 零外部依赖,性能更优
- 完整TypeScript类型支持
- 未来原生浏览器兼容

Breaking Changes: 无,装饰器语法保持兼容
2025-09-02 06:49:32 +08:00
63c1e4df63 feat: 添加路由装饰器系统和全局异常处理
- 新增装饰器支持(@Get, @Post, @Put, @Delete, @Patch, @Controller)
- 实现路由自动注册机制(RouteScanner)
- 添加全局异常处理中间件(Exception)
- 实现统一响应体格式(ApiResponse)
- 新增请求体解析中间件(BodyParser)
- 重构控制器为类模式,支持装饰器路由
- 添加示例用户控制器(UserController)
- 更新TypeScript配置支持装饰器
- 添加reflect-metadata依赖
- 完善项目文档

Breaking Changes:
- 控制器现在返回数据而不是直接设置ctx.body
- 新增统一的API响应格式
2025-09-01 00:14:17 +08:00
47f36cd625 feat: add backend 2025-08-28 23:25:59 +08:00
2edf8753a7 feat: add base layout 2025-08-19 22:47:32 +08:00
69 changed files with 6785 additions and 343 deletions

5
apps/server/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

View 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保留数据历史。

View File

@@ -0,0 +1,231 @@
# 路由装饰器使用指南TC39 标准)
本项目使用符合 **TC39 Stage 3 提案**的标准装饰器语法,提供现代化的路由定义方式。
## TC39 装饰器优势
-**标准化**:符合 ECMAScript 官方标准提案
-**类型安全**:完整的 TypeScript 类型支持
-**性能优化**:无需 reflect-metadata 依赖
-**未来兼容**:随着标准发展自动获得浏览器支持
## 快速开始
### 1. 创建控制器
```typescript
import type { Context } from 'koa';
import { Controller, Get, Post, Put, Delete } from '../decorators/route.ts';
import { BusinessError } from '../middlewares/exception.ts';
@Controller('/api-prefix') // 控制器路由前缀
export class MyController {
@Get('/users')
async getUsers(ctx: Context) {
// 直接返回数据,自动包装成统一响应格式
return { users: [] };
}
@Post('/users')
async createUser(ctx: Context) {
const userData = (ctx.request as any).body;
// 业务逻辑处理...
return { id: 1, ...userData };
}
@Put('/users/:id')
async updateUser(ctx: Context) {
const { id } = ctx.params;
const userData = (ctx.request as any).body;
// 业务逻辑处理...
return { id, ...userData };
}
@Delete('/users/:id')
async deleteUser(ctx: Context) {
const { id } = ctx.params;
// 业务逻辑处理...
return { success: true };
}
}
```
### 2. 注册控制器
`middlewares/router.ts``registerDecoratorRoutes()` 方法中添加你的控制器:
```typescript
this.routeScanner.registerControllers([
ApplicationController,
UserController,
MyController // 添加你的控制器
]);
```
## 可用装饰器
### HTTP方法装饰器TC39 标准)
- `@Get(path)` - GET 请求
- `@Post(path)` - POST 请求
- `@Put(path)` - PUT 请求
- `@Delete(path)` - DELETE 请求
- `@Patch(path)` - PATCH 请求
### 控制器装饰器TC39 标准)
- `@Controller(prefix)` - 控制器路由前缀
## TC39 装饰器特性
### 1. 标准语法
```typescript
// TC39 标准装饰器使用 addInitializer
@Get('/users')
async getUsers(ctx: Context) {
return userData;
}
```
### 2. 类型安全
```typescript
// 完整的 TypeScript 类型检查
@Controller('/api')
export class ApiController {
@Get('/health')
healthCheck(): { status: string } {
return { status: 'ok' };
}
}
```
### 3. 无外部依赖
```typescript
// 不再需要 reflect-metadata
// 使用内置的 WeakMap 存储元数据
```
## 配置要求
### TypeScript 配置
```json
{
"compilerOptions": {
"experimentalDecorators": false, // 关闭实验性装饰器
"emitDecoratorMetadata": false, // 关闭元数据发射
"target": "ES2022", // 目标 ES2022+
"useDefineForClassFields": false // 兼容装饰器行为
}
}
```
### 依赖
```json
{
"dependencies": {
// 无需 reflect-metadata
}
}
```
## 路径拼接规则
最终的API路径 = 全局前缀 + 控制器前缀 + 方法路径
例如:
- 全局前缀:`/api`
- 控制器前缀:`/user`
- 方法路径:`/list`
- 最终路径:`/api/user/list`
## 响应格式
控制器方法只需要返回数据,系统会自动包装成统一响应格式:
```json
{
"code": 0,
"message": "操作成功",
"data": { /* 控制器返回的数据 */ },
"timestamp": 1693478400000
}
```
## 异常处理
可以抛出 `BusinessError` 来返回业务异常:
```typescript
import { BusinessError } from '../middlewares/exception.ts';
@Get('/users/:id')
async getUser(ctx: Context) {
const { id } = ctx.params;
if (!id) {
throw new BusinessError('用户ID不能为空', 1001, 400);
}
// 正常业务逻辑...
return userData;
}
```
## 现有路由
项目中已注册的路由:
### ApplicationController
- `GET /api/application/list` - 获取应用列表
- `GET /api/application/detail/:id` - 获取应用详情
### UserController
- `GET /api/user/list` - 获取用户列表
- `GET /api/user/detail/:id` - 获取用户详情
- `POST /api/user` - 创建用户
- `PUT /api/user/:id` - 更新用户
- `DELETE /api/user/:id` - 删除用户
- `GET /api/user/search` - 搜索用户
## 与旧版本装饰器的区别
| 特性 | 实验性装饰器 | TC39 标准装饰器 |
|------|-------------|----------------|
| 标准化 | ❌ TypeScript 特有 | ✅ ECMAScript 标准 |
| 依赖 | ❌ 需要 reflect-metadata | ✅ 零依赖 |
| 性能 | ❌ 运行时反射 | ✅ 编译时优化 |
| 类型安全 | ⚠️ 部分支持 | ✅ 完整支持 |
| 未来兼容 | ❌ 可能被废弃 | ✅ 持续演进 |
## 迁移指南
从实验性装饰器迁移到 TC39 标准装饰器:
1. **更新 tsconfig.json**
```json
{
"experimentalDecorators": false,
"emitDecoratorMetadata": false
}
```
2. **移除依赖**
```bash
pnpm remove reflect-metadata
```
3. **代码无需修改**
- 装饰器语法保持不变
- 控制器代码无需修改
- 自动兼容新标准
## 注意事项
1. 需要 TypeScript 5.0+ 支持
2. 需要 Node.js 16+ 运行环境
3. 控制器类需要导出并在路由中间件中注册
4. 控制器方法应该返回数据而不是直接操作 `ctx.body`
5. TC39 装饰器使用 `addInitializer` 进行初始化,性能更优

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

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

13
apps/server/app.ts Normal file
View 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);
});

View 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;
}

View File

@@ -0,0 +1,45 @@
import { Controller, Get, Post } from '../../decorators/route.ts';
import type { Prisma } from '../../generated/prisma/index.js';
import prisma from '../../libs/db.ts';
import type { Context } from 'koa';
@Controller('/deployments')
export class DeploymentController {
@Get('')
async list(ctx: Context) {
const { page = 1, pageSize = 10 } = ctx.query;
const result = await prisma.deployment.findMany({
where: {
valid: 1,
},
take: Number(pageSize),
skip: (Number(page) - 1) * Number(pageSize),
orderBy: {
createdAt: 'desc',
},
});
const total = await prisma.deployment.count();
return {
data: result,
page: Number(page),
pageSize: Number(pageSize),
total: total,
};
}
@Post('')
async create(ctx: Context) {
const body = ctx.request.body as Prisma.DeploymentCreateInput;
prisma.deployment.create({
data: {
branch: body.branch,
commitHash: body.commitHash,
commitMessage: body.commitMessage,
valid: 1,
},
});
}
}

View File

@@ -0,0 +1,6 @@
// 控制器统一导出
export { ProjectController } from './project/index.ts';
export { UserController } from './user/index.ts';
export { AuthController } from './auth/index.ts';
export { DeploymentController } from './deployment/index.ts';
export { PipelineController } from './pipeline/index.ts'

View File

@@ -0,0 +1,22 @@
import type { Context } from 'koa';
import { Controller, Get, Post } from '../../decorators/route.ts';
import prisma from '../../libs/db.ts';
@Controller('/pipelines')
export class PipelineController {
@Get('/:id')
async get(ctx: Context) {
const id = ctx.params.id;
const pipeline = await prisma.pipeline.findUnique({
where: {
id: id,
},
});
return pipeline;
}
@Post('')
async create(ctx: Context) {
}
}

View 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;
}
}

View 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>;

View 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
};
}
}

View 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
View 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
View 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();

View 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();

View 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;
}
}

View 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();
});
}
}

View 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());
}
}

View 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') || '*';
},
}),
);
}
}

View 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(),
};
}

View 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);
}

View 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`)
});
}
}

View File

@@ -0,0 +1,74 @@
import KoaRouter from '@koa/router';
import type Koa from 'koa';
import type { Middleware } from './types.ts';
import { RouteScanner } from '../libs/route-scanner.ts';
import {
ProjectController,
UserController,
AuthController,
DeploymentController,
PipelineController
} from '../controllers/index.ts';
import { log } from '../libs/logger.ts';
export class Router implements Middleware {
private router: KoaRouter;
private routeScanner: RouteScanner;
private readonly TAG = 'Router';
constructor() {
this.router = new KoaRouter({
prefix: '/api',
});
// 初始化路由扫描器
this.routeScanner = new RouteScanner('/api');
// 注册装饰器路由
this.registerDecoratorRoutes();
// 注册传统路由(向后兼容)
this.registerTraditionalRoutes();
}
/**
* 注册装饰器路由
*/
private registerDecoratorRoutes(): void {
// 注册所有使用装饰器的控制器
this.routeScanner.registerControllers([
ProjectController,
UserController,
AuthController,
DeploymentController,
PipelineController
]);
// 输出注册的路由信息
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());
}
}

View 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,
),
);
}
}

View File

@@ -0,0 +1,5 @@
import type Koa from 'koa';
export abstract class Middleware {
abstract apply(app: Koa, options?: unknown): void;
}

36
apps/server/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"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",
"zx": "^8.8.2"
},
"devDependencies": {
"@tsconfig/node-ts": "^23.6.1",
"@tsconfig/node22": "^22.0.2",
"@types/koa": "^3.0.0",
"@types/koa-bodyparser": "^4.3.12",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4",
"@types/node": "^24.3.0",
"prisma": "^6.15.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

Binary file not shown.

View File

@@ -0,0 +1,95 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-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
// Relations
deployments Deployment[]
pipelines Pipeline[]
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
}
model User {
id Int @id @default(autoincrement())
username String
login String
email String
avatar_url String?
active Boolean @default(true)
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String @default("system")
updatedBy String @default("system")
}
model Pipeline {
id Int @id @default(autoincrement())
name String
description String?
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
// Relations
projectId Int?
Project Project? @relation(fields: [projectId], references: [id])
steps Step[]
}
model Step {
id Int @id @default(autoincrement())
name String
description String?
order Int
status String?
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
pipelineId Int
pipeline Pipeline @relation(fields: [pipelineId], references: [id])
}
model Deployment {
id Int @id @default(autoincrement())
branch String
env String?
status String // pending, running, success, failed, cancelled
commitHash String?
commitMessage String?
buildLog String?
startedAt DateTime @default(now())
finishedAt DateTime?
valid Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
projectId Int
Project Project? @relation(fields: [projectId], references: [id])
pipelineId Int
}

10
apps/server/tsconfig.json Normal file
View 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
View File

@@ -0,0 +1 @@
BASE_URL=http://192.168.1.36:3001

37
apps/web/package.json Normal file
View 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"
}

View 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',
}),
],
},
},
});

View 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

View 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

View File

@@ -0,0 +1,10 @@
import React, { useEffect } from 'react';
export function useAsyncEffect(
effect: () => Promise<void>,
deps: React.DependencyList,
) {
useEffect(() => {
effect();
}, [...deps]);
}

View File

@@ -1,17 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from '@pages/App';
import { BrowserRouter } from 'react-router';
import { useGlobalStore } from './stores/global';
const rootEl = document.getElementById('root');
if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
useGlobalStore.getState().refreshUser();
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
<BrowserRouter>
<App />
</BrowserRouter>,
);
}

View 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
View File

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

View 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;

View 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;

View 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();

View 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>;

View File

@@ -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;

View File

@@ -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;

View 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&#10;npm test&#10;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;

View 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();

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;
}

View 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;
}

View File

@@ -0,0 +1 @@
export * from './request';

View 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();

View 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 });
},
}));

View File

@@ -22,7 +22,9 @@
"noUnusedParameters": true,
"paths": {
"@pages/*": ["./src/pages/*"],
"@styles/*": ["./src/styles/*"]
"@styles/*": ["./src/styles/*"],
"@assets/*": ["./src/assets/*"],
"@shared": ["./src/shared"]
}
},
"include": ["src"]

View File

@@ -1,32 +1,15 @@
{
"name": "foka-ci",
"name": "ark-ci",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "",
"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",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.8.0"
"dev": "pnpm --parallel -r run dev"
},
"devDependencies": {
"@arco-plugins/unplugin-react": "2.0.0-beta.5",
"@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"
"@biomejs/biome": "2.0.6"
},
"keywords": ["ci", "ark", "ark-ci"],
"author": "hurole",
"license": "ISC",
"packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321"
}

3005
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'apps/*'

View File

@@ -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",
}),
],
},
},
});

View File

@@ -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;

View File

@@ -1,6 +0,0 @@
function Home() {
return <div>Home</div>;
}
export default Home;

View File

@@ -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;