feat: 项目的增删改查
This commit is contained in:
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,保留数据历史。
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Context } from 'koa';
|
import type { Context } from 'koa';
|
||||||
import { Controller, Get, Post } from '../decorators/route.ts';
|
import { Controller, Get, Post } from '../../decorators/route.ts';
|
||||||
import prisma from '../libs/db.ts';
|
import prisma from '../../libs/db.ts';
|
||||||
import { log } from '../libs/logger.ts';
|
import { log } from '../../libs/logger.ts';
|
||||||
import { gitea } from '../libs/gitea.ts';
|
import { gitea } from '../../libs/gitea.ts';
|
||||||
|
|
||||||
@Controller('/auth')
|
@Controller('/auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
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';
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 } from '../decorators/route.ts';
|
|
||||||
|
|
||||||
@Controller('/project')
|
|
||||||
export class ProjectController {
|
|
||||||
@Get('/list')
|
|
||||||
async list(ctx: Context) {
|
|
||||||
log.debug('app', 'session %o', ctx.session);
|
|
||||||
try {
|
|
||||||
const list = await prisma.application.findMany({
|
|
||||||
where: {
|
|
||||||
valid: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 直接返回数据,由路由中间件统一包装成响应格式
|
|
||||||
return list;
|
|
||||||
} catch (error) {
|
|
||||||
// 抛出业务异常,由全局异常处理中间件捕获
|
|
||||||
throw new BusinessError('获取应用列表失败', 1001, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/detail/:id')
|
|
||||||
async detail(ctx: Context) {
|
|
||||||
const { id } = ctx.params;
|
|
||||||
const app = await prisma.application.findUnique({
|
|
||||||
where: { id: Number(id) },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
throw new BusinessError('应用不存在', 1002, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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>;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Context } from 'koa';
|
import type { Context } from 'koa';
|
||||||
import { Controller, Get, Post, Put, Delete } from '../decorators/route.ts';
|
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
|
||||||
import { BusinessError } from '../middlewares/exception.ts';
|
import { BusinessError } from '../../middlewares/exception.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户控制器
|
* 用户控制器
|
||||||
@@ -51,10 +51,10 @@ function createMethodDecorator(method: HttpMethod) {
|
|||||||
// 在类初始化时执行
|
// 在类初始化时执行
|
||||||
context.addInitializer(function () {
|
context.addInitializer(function () {
|
||||||
// 使用 this.constructor 时需要类型断言
|
// 使用 this.constructor 时需要类型断言
|
||||||
const constructor = (this as any).constructor;
|
const ctor = (this as any).constructor;
|
||||||
|
|
||||||
// 获取现有的路由元数据
|
// 获取现有的路由元数据
|
||||||
const existingRoutes: RouteMetadata[] = getMetadata(ROUTE_METADATA_KEY, constructor) || [];
|
const existingRoutes: RouteMetadata[] = getMetadata(ROUTE_METADATA_KEY, ctor) || [];
|
||||||
|
|
||||||
// 添加新的路由元数据
|
// 添加新的路由元数据
|
||||||
const newRoute: RouteMetadata = {
|
const newRoute: RouteMetadata = {
|
||||||
@@ -66,7 +66,7 @@ function createMethodDecorator(method: HttpMethod) {
|
|||||||
existingRoutes.push(newRoute);
|
existingRoutes.push(newRoute);
|
||||||
|
|
||||||
// 保存路由元数据到类的构造函数上
|
// 保存路由元数据到类的构造函数上
|
||||||
setMetadata(ROUTE_METADATA_KEY, existingRoutes, constructor);
|
setMetadata(ROUTE_METADATA_KEY, existingRoutes, ctor);
|
||||||
});
|
});
|
||||||
|
|
||||||
return target;
|
return target;
|
||||||
@@ -125,13 +125,13 @@ export function Controller(prefix: string = '') {
|
|||||||
/**
|
/**
|
||||||
* 获取控制器的路由元数据
|
* 获取控制器的路由元数据
|
||||||
*/
|
*/
|
||||||
export function getRouteMetadata(constructor: any): RouteMetadata[] {
|
export function getRouteMetadata(ctor: any): RouteMetadata[] {
|
||||||
return getMetadata(ROUTE_METADATA_KEY, constructor) || [];
|
return getMetadata(ROUTE_METADATA_KEY, ctor) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取控制器的路由前缀
|
* 获取控制器的路由前缀
|
||||||
*/
|
*/
|
||||||
export function getControllerPrefix(constructor: any): string {
|
export function getControllerPrefix(ctor: any): string {
|
||||||
return getMetadata('prefix', constructor) || '';
|
return getMetadata('prefix', ctor) || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type Koa from 'koa';
|
import type Koa from 'koa';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Middleware } from './types.ts';
|
import type { Middleware } from './types.ts';
|
||||||
import { log } from '../libs/logger.ts';
|
import { log } from '../libs/logger.ts';
|
||||||
|
|
||||||
@@ -51,7 +52,22 @@ export class Exception implements Middleware {
|
|||||||
* 处理错误
|
* 处理错误
|
||||||
*/
|
*/
|
||||||
private handleError(ctx: Koa.Context, error: any): void {
|
private handleError(ctx: Koa.Context, error: any): void {
|
||||||
if (error instanceof BusinessError) {
|
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);
|
this.sendResponse(ctx, error.code, error.message, null, error.httpStatus);
|
||||||
} else if (error.status) {
|
} else if (error.status) {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import KoaRouter from '@koa/router';
|
|||||||
import type Koa from 'koa';
|
import type Koa from 'koa';
|
||||||
import type { Middleware } from './types.ts';
|
import type { Middleware } from './types.ts';
|
||||||
import { RouteScanner } from '../libs/route-scanner.ts';
|
import { RouteScanner } from '../libs/route-scanner.ts';
|
||||||
import { ProjectController } from '../controllers/project.ts';
|
import {
|
||||||
import { UserController } from '../controllers/user.ts';
|
ProjectController,
|
||||||
import { AuthController } from '../controllers/auth.ts';
|
UserController,
|
||||||
|
AuthController,
|
||||||
|
} from '../controllers/index.ts';
|
||||||
import { log } from '../libs/logger.ts';
|
import { log } from '../libs/logger.ts';
|
||||||
|
|
||||||
export class Router implements Middleware {
|
export class Router implements Middleware {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"koa-bodyparser": "^4.4.1",
|
"koa-bodyparser": "^4.4.1",
|
||||||
"koa-session": "^7.0.2",
|
"koa-session": "^7.0.2",
|
||||||
"pino": "^9.9.1",
|
"pino": "^9.9.1",
|
||||||
"pino-pretty": "^13.1.1"
|
"pino-pretty": "^13.1.1",
|
||||||
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node-ts": "^23.6.1",
|
"@tsconfig/node-ts": "^23.6.1",
|
||||||
|
|||||||
Binary file not shown.
@@ -11,7 +11,7 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Application {
|
model Project {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
|
|||||||
102
apps/web/src/pages/project/components/CreateProjectModal.tsx
Normal file
102
apps/web/src/pages/project/components/CreateProjectModal.tsx
Normal 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;
|
||||||
115
apps/web/src/pages/project/components/EditProjectModal.tsx
Normal file
115
apps/web/src/pages/project/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;
|
||||||
178
apps/web/src/pages/project/components/ProjectCard.tsx
Normal file
178
apps/web/src/pages/project/components/ProjectCard.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { Card, Tag, Avatar, Space, Typography, Button, Tooltip, Dropdown, Menu, Modal } from '@arco-design/web-react';
|
||||||
|
import { IconBranch, IconCalendar, IconEye, IconCloud, IconEdit, IconMore, IconDelete } from '@arco-design/web-react/icon';
|
||||||
|
import type { Project } from '../types';
|
||||||
|
import IconGitea from '@assets/images/gitea.svg?react';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: Project;
|
||||||
|
onEdit?: (project: Project) => void;
|
||||||
|
onDelete?: (project: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ project, onEdit, onDelete }: ProjectCardProps) {
|
||||||
|
// 处理删除操作
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="foka-card !rounded-xl border border-gray-200 h-[320px] hover:border-blue-200 transition-all duration-300 hover:shadow-md"
|
||||||
|
hoverable
|
||||||
|
bodyStyle={{ padding: '20px' }}
|
||||||
|
>
|
||||||
|
{/* 项目头部 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<IconEye />}
|
||||||
|
className="text-gray-500 hover:text-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="text-blue-500 hover:text-blue-600 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
管理项目 →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectCard;
|
||||||
@@ -1,112 +1,108 @@
|
|||||||
import { Card, Grid, Link, Tag, Avatar, Space, Typography, Button } from '@arco-design/web-react';
|
import { Grid, Typography, Button, Message } from '@arco-design/web-react';
|
||||||
import { IconBranch, IconCalendar, IconEye } from '@arco-design/web-react/icon';
|
import { IconPlus } from '@arco-design/web-react/icon';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Project } from './types';
|
import type { Project } from './types';
|
||||||
import { useAsyncEffect } from '../../hooks/useAsyncEffect';
|
import { useAsyncEffect } from '../../hooks/useAsyncEffect';
|
||||||
import { projectService } from './service';
|
import { projectService } from './service';
|
||||||
import IconGitea from '@assets/images/gitea.svg?react'
|
import ProjectCard from './components/ProjectCard';
|
||||||
|
import EditProjectModal from './components/EditProjectModal';
|
||||||
|
import CreateProjectModal from './components/CreateProjectModal';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
function ProjectPage() {
|
function ProjectPage() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
const list = await projectService.list();
|
const response = await projectService.list();
|
||||||
setProjects(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 (
|
return (
|
||||||
<div className="p-6 bg-gray-100 min-h-screen">
|
<div className="p-6 bg-gray-100 min-h-screen">
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<Typography.Title heading={2} className="!m-0 !text-gray-900">
|
<div>
|
||||||
我的项目
|
<Typography.Title heading={2} className="!m-0 !text-gray-900">
|
||||||
</Typography.Title>
|
我的项目
|
||||||
<Text type="secondary">管理和查看您的所有项目</Text>
|
</Typography.Title>
|
||||||
|
<Text type="secondary">管理和查看您的所有项目</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconPlus />}
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
className="!rounded-lg"
|
||||||
|
>
|
||||||
|
新建项目
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Grid.Row gutter={[16, 16]}>
|
<Grid.Row gutter={[16, 16]}>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Grid.Col key={project.id} span={8}>
|
<Grid.Col key={project.id} span={8}>
|
||||||
<Card
|
<ProjectCard
|
||||||
className="foka-card !rounded-xl border border-gray-200 h-[280px] hover:border-blue-200"
|
project={project}
|
||||||
hoverable
|
onEdit={handleEditProject}
|
||||||
bodyStyle={{ padding: '20px' }}
|
onDelete={handleDeleteProject}
|
||||||
>
|
/>
|
||||||
{/* 项目头部 */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Avatar
|
|
||||||
size={40}
|
|
||||||
className="bg-blue-600 text-white text-base font-semibold"
|
|
||||||
>
|
|
||||||
{project.name.charAt(0).toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
<div className="ml-3">
|
|
||||||
<Typography.Title
|
|
||||||
heading={5}
|
|
||||||
className="!m-0 !text-base !font-semibold"
|
|
||||||
>
|
|
||||||
{project.name}
|
|
||||||
</Typography.Title>
|
|
||||||
<Text type="secondary" className="text-xs">
|
|
||||||
更新于 2天前
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Tag color="blue" size="small">
|
|
||||||
活跃
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 项目描述 */}
|
|
||||||
<Paragraph
|
|
||||||
className="!m-0 !mb-4 !text-gray-600 !text-sm !leading-6 h-[42px] overflow-hidden line-clamp-2"
|
|
||||||
>
|
|
||||||
{project.description || '暂无描述'}
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
{/* 项目信息 */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="mb-2 flex items-center">
|
|
||||||
<IconGitea className="mr-1.5 w-4" />
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<IconEye />}
|
|
||||||
className="text-gray-500"
|
|
||||||
>
|
|
||||||
查看详情
|
|
||||||
</Button>
|
|
||||||
<Link className="text-xs font-medium">
|
|
||||||
管理项目 →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
))}
|
))}
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
|
|
||||||
|
<EditProjectModal
|
||||||
|
visible={editModalVisible}
|
||||||
|
project={editingProject}
|
||||||
|
onCancel={handleEditCancel}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateProjectModal
|
||||||
|
visible={createModalVisible}
|
||||||
|
onCancel={handleCreateCancel}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,70 @@
|
|||||||
import { net, type APIResponse } from "@shared";
|
import { net, type APIResponse } from "@shared";
|
||||||
import type { Project } from "./types";
|
import type { Project } from "./types";
|
||||||
|
|
||||||
|
interface ProjectListResponse {
|
||||||
|
data: Project[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectQueryParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class ProjectService {
|
class ProjectService {
|
||||||
|
// GET /api/projects - 获取项目列表
|
||||||
async list() {
|
async list(params?: ProjectQueryParams) {
|
||||||
const { data } = await net.request<APIResponse<Project[]>>({
|
const { data } = await net.request<APIResponse<ProjectListResponse>>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/api/project/list',
|
url: '/api/projects',
|
||||||
})
|
params,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/projects/:id - 获取单个项目
|
||||||
|
async show(id: string) {
|
||||||
|
const { data } = await net.request<APIResponse<Project>>({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/projects/${id}`,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/projects - 创建项目
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/projects/:id - 更新项目
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id - 删除项目
|
||||||
|
async delete(id: string) {
|
||||||
|
await net.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/projects/${id}`,
|
||||||
|
});
|
||||||
|
// DELETE 成功返回 204,无内容
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const projectService = new ProjectService();
|
export const projectService = new ProjectService();
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
repository: string;
|
repository: string;
|
||||||
env: Record<string, string>;
|
valid: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
status: BuildStatus;
|
status: BuildStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
|||||||
pino-pretty:
|
pino-pretty:
|
||||||
specifier: ^13.1.1
|
specifier: ^13.1.1
|
||||||
version: 13.1.1
|
version: 13.1.1
|
||||||
|
zod:
|
||||||
|
specifier: ^4.1.5
|
||||||
|
version: 4.1.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tsconfig/node-ts':
|
'@tsconfig/node-ts':
|
||||||
specifier: ^23.6.1
|
specifier: ^23.6.1
|
||||||
@@ -1909,6 +1912,9 @@ packages:
|
|||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
zod@4.1.5:
|
||||||
|
resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
@@ -3656,3 +3662,5 @@ snapshots:
|
|||||||
yallist@5.0.0: {}
|
yallist@5.0.0: {}
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
|
zod@4.1.5: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user