diff --git a/apps/server/README-RESTful-API.md b/apps/server/README-RESTful-API.md new file mode 100644 index 0000000..a84c7f5 --- /dev/null +++ b/apps/server/README-RESTful-API.md @@ -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,保留数据历史。 diff --git a/apps/server/controllers/auth.ts b/apps/server/controllers/auth/index.ts similarity index 90% rename from apps/server/controllers/auth.ts rename to apps/server/controllers/auth/index.ts index d939db2..e0e0f75 100644 --- a/apps/server/controllers/auth.ts +++ b/apps/server/controllers/auth/index.ts @@ -1,8 +1,8 @@ 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'; +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 { diff --git a/apps/server/controllers/index.ts b/apps/server/controllers/index.ts new file mode 100644 index 0000000..32f5277 --- /dev/null +++ b/apps/server/controllers/index.ts @@ -0,0 +1,4 @@ +// 控制器统一导出 +export { ProjectController } from './project/index.ts'; +export { UserController } from './user/index.ts'; +export { AuthController } from './auth/index.ts'; diff --git a/apps/server/controllers/project.ts b/apps/server/controllers/project.ts deleted file mode 100644 index cc11cdd..0000000 --- a/apps/server/controllers/project.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/server/controllers/project/index.ts b/apps/server/controllers/project/index.ts new file mode 100644 index 0000000..c017d0f --- /dev/null +++ b/apps/server/controllers/project/index.ts @@ -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; + } +} diff --git a/apps/server/controllers/project/schema.ts b/apps/server/controllers/project/schema.ts new file mode 100644 index 0000000..cff4ebb --- /dev/null +++ b/apps/server/controllers/project/schema.ts @@ -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; +export type UpdateProjectInput = z.infer; +export type ListProjectQuery = z.infer; +export type ProjectIdParams = z.infer; diff --git a/apps/server/controllers/user.ts b/apps/server/controllers/user/index.ts similarity index 94% rename from apps/server/controllers/user.ts rename to apps/server/controllers/user/index.ts index 1cef62f..c24089b 100644 --- a/apps/server/controllers/user.ts +++ b/apps/server/controllers/user/index.ts @@ -1,6 +1,6 @@ import type { Context } from 'koa'; -import { Controller, Get, Post, Put, Delete } from '../decorators/route.ts'; -import { BusinessError } from '../middlewares/exception.ts'; +import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts'; +import { BusinessError } from '../../middlewares/exception.ts'; /** * 用户控制器 diff --git a/apps/server/decorators/route.ts b/apps/server/decorators/route.ts index 99330a5..644379e 100644 --- a/apps/server/decorators/route.ts +++ b/apps/server/decorators/route.ts @@ -51,10 +51,10 @@ function createMethodDecorator(method: HttpMethod) { // 在类初始化时执行 context.addInitializer(function () { // 使用 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 = { @@ -66,7 +66,7 @@ function createMethodDecorator(method: HttpMethod) { existingRoutes.push(newRoute); // 保存路由元数据到类的构造函数上 - setMetadata(ROUTE_METADATA_KEY, existingRoutes, constructor); + setMetadata(ROUTE_METADATA_KEY, existingRoutes, ctor); }); return target; @@ -125,13 +125,13 @@ export function Controller(prefix: string = '') { /** * 获取控制器的路由元数据 */ -export function getRouteMetadata(constructor: any): RouteMetadata[] { - return getMetadata(ROUTE_METADATA_KEY, constructor) || []; +export function getRouteMetadata(ctor: any): RouteMetadata[] { + return getMetadata(ROUTE_METADATA_KEY, ctor) || []; } /** * 获取控制器的路由前缀 */ -export function getControllerPrefix(constructor: any): string { - return getMetadata('prefix', constructor) || ''; +export function getControllerPrefix(ctor: any): string { + return getMetadata('prefix', ctor) || ''; } diff --git a/apps/server/middlewares/exception.ts b/apps/server/middlewares/exception.ts index 141a1a4..c9d3b59 100644 --- a/apps/server/middlewares/exception.ts +++ b/apps/server/middlewares/exception.ts @@ -1,4 +1,5 @@ import type Koa from 'koa'; +import { z } from 'zod'; import type { Middleware } from './types.ts'; import { log } from '../libs/logger.ts'; @@ -51,7 +52,22 @@ export class Exception implements Middleware { * 处理错误 */ 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); } else if (error.status) { diff --git a/apps/server/middlewares/router.ts b/apps/server/middlewares/router.ts index e3e8fda..306bc3c 100644 --- a/apps/server/middlewares/router.ts +++ b/apps/server/middlewares/router.ts @@ -2,9 +2,11 @@ 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 } from '../controllers/project.ts'; -import { UserController } from '../controllers/user.ts'; -import { AuthController } from '../controllers/auth.ts'; +import { + ProjectController, + UserController, + AuthController, +} from '../controllers/index.ts'; import { log } from '../libs/logger.ts'; export class Router implements Middleware { diff --git a/apps/server/package.json b/apps/server/package.json index 7de68a8..ae60dde 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,7 +17,8 @@ "koa-bodyparser": "^4.4.1", "koa-session": "^7.0.2", "pino": "^9.9.1", - "pino-pretty": "^13.1.1" + "pino-pretty": "^13.1.1", + "zod": "^4.1.5" }, "devDependencies": { "@tsconfig/node-ts": "^23.6.1", diff --git a/apps/server/prisma/data/dev.db b/apps/server/prisma/data/dev.db index ab7e1e6..07d8db1 100644 Binary files a/apps/server/prisma/data/dev.db and b/apps/server/prisma/data/dev.db differ diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 41068fa..7b6b8a9 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -11,7 +11,7 @@ datasource db { url = env("DATABASE_URL") } -model Application { +model Project { id Int @id @default(autoincrement()) name String description String? diff --git a/apps/web/src/pages/project/components/CreateProjectModal.tsx b/apps/web/src/pages/project/components/CreateProjectModal.tsx new file mode 100644 index 0000000..13eae4d --- /dev/null +++ b/apps/web/src/pages/project/components/CreateProjectModal.tsx @@ -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 ( + + 取消 + , + , + ]} + style={{ width: 500 }} + > +
+ + + + + + + + + + + +
+
+ ); +} + +export default CreateProjectModal; diff --git a/apps/web/src/pages/project/components/EditProjectModal.tsx b/apps/web/src/pages/project/components/EditProjectModal.tsx new file mode 100644 index 0000000..fa86e42 --- /dev/null +++ b/apps/web/src/pages/project/components/EditProjectModal.tsx @@ -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 ( + + 取消 + , + , + ]} + style={{ width: 500 }} + > +
+ + + + + + + + + + + +
+
+ ); +} + +export default EditProjectModal; diff --git a/apps/web/src/pages/project/components/ProjectCard.tsx b/apps/web/src/pages/project/components/ProjectCard.tsx new file mode 100644 index 0000000..1f7c650 --- /dev/null +++ b/apps/web/src/pages/project/components/ProjectCard.tsx @@ -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 ( +
+ +
+ {environments.map((env) => ( + + + {env.icon} + {env.name} + + + ))} +
+
+ ); + }; + + return ( + + {/* 项目头部 */} +
+
+ + {project.name.charAt(0).toUpperCase()} + +
+ + {project.name} + + + 更新于 2天前 + +
+
+
+ + 活跃 + + + onEdit?.(project)} + > + + 编辑 + + handleDelete()} + className="text-red-500" + > + + 删除 + + + } + position="br" + > +
+
+ + {/* 项目描述 */} + + {project.description || '暂无描述'} + + + {/* 环境信息 */} + {renderEnvironmentTags()} + + {/* 项目信息 */} +
+
+ + + {project.repository} + +
+ +
+ + main +
+
+ + 3个提交 +
+
+
+ + {/* 操作按钮 */} +
+ + +
+
+ ); +} + +export default ProjectCard; diff --git a/apps/web/src/pages/project/index.tsx b/apps/web/src/pages/project/index.tsx index a0d60b4..72422f4 100644 --- a/apps/web/src/pages/project/index.tsx +++ b/apps/web/src/pages/project/index.tsx @@ -1,112 +1,108 @@ -import { Card, Grid, Link, Tag, Avatar, Space, Typography, Button } from '@arco-design/web-react'; -import { IconBranch, IconCalendar, IconEye } from '@arco-design/web-react/icon'; +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 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() { const [projects, setProjects] = useState([]); + const [editModalVisible, setEditModalVisible] = useState(false); + const [editingProject, setEditingProject] = useState(null); + const [createModalVisible, setCreateModalVisible] = useState(false); useAsyncEffect(async () => { - const list = await projectService.list(); - setProjects(list); + 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 (
-
- - 我的项目 - - 管理和查看您的所有项目 +
+
+ + 我的项目 + + 管理和查看您的所有项目 +
+
{projects.map((project) => ( - - {/* 项目头部 */} -
-
- - {project.name.charAt(0).toUpperCase()} - -
- - {project.name} - - - 更新于 2天前 - -
-
- - 活跃 - -
- - {/* 项目描述 */} - - {project.description || '暂无描述'} - - - {/* 项目信息 */} -
-
- - - {project.repository} - -
- -
- - main -
-
- - 3个提交 -
-
-
- - {/* 操作按钮 */} -
- - - 管理项目 → - -
-
+
))}
+ + + +
); } diff --git a/apps/web/src/pages/project/service.ts b/apps/web/src/pages/project/service.ts index c2ebba8..106ac84 100644 --- a/apps/web/src/pages/project/service.ts +++ b/apps/web/src/pages/project/service.ts @@ -1,16 +1,70 @@ import { net, type APIResponse } from "@shared"; 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 { - - async list() { - const { data } = await net.request>({ + // GET /api/projects - 获取项目列表 + async list(params?: ProjectQueryParams) { + const { data } = await net.request>({ method: 'GET', - url: '/api/project/list', - }) + url: '/api/projects', + params, + }); return data; } + + // GET /api/projects/:id - 获取单个项目 + async show(id: string) { + const { data } = await net.request>({ + 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>({ + 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>({ + 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(); diff --git a/apps/web/src/pages/project/types.ts b/apps/web/src/pages/project/types.ts index 05fd4a9..3a62af9 100644 --- a/apps/web/src/pages/project/types.ts +++ b/apps/web/src/pages/project/types.ts @@ -9,7 +9,10 @@ export interface Project { name: string; description: string; repository: string; - env: Record; + valid: number; createdAt: string; + updatedAt: string; + createdBy: string; + updatedBy: string; status: BuildStatus; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af15258..eaf888f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: pino-pretty: specifier: ^13.1.1 version: 13.1.1 + zod: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: '@tsconfig/node-ts': specifier: ^23.6.1 @@ -1909,6 +1912,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.5: + resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3656,3 +3662,5 @@ snapshots: yallist@5.0.0: {} zod@3.25.76: {} + + zod@4.1.5: {}