feat: 标准化响应体

This commit is contained in:
2026-01-11 16:39:59 +08:00
parent cd50716dc6
commit 45047f40aa
26 changed files with 313 additions and 250 deletions

View File

@@ -80,6 +80,7 @@ this.routeScanner.registerControllers([
## TC39 装饰器特性 ## TC39 装饰器特性
### 1. 标准语法 ### 1. 标准语法
```typescript ```typescript
// TC39 标准装饰器使用 addInitializer // TC39 标准装饰器使用 addInitializer
@Get('/users') @Get('/users')
@@ -89,6 +90,7 @@ async getUsers(ctx: Context) {
``` ```
### 2. 类型安全 ### 2. 类型安全
```typescript ```typescript
// 完整的 TypeScript 类型检查 // 完整的 TypeScript 类型检查
@Controller('/api') @Controller('/api')
@@ -101,6 +103,7 @@ export class ApiController {
``` ```
### 3. 无外部依赖 ### 3. 无外部依赖
```typescript ```typescript
// 不再需要 reflect-metadata // 不再需要 reflect-metadata
// 使用内置的 WeakMap 存储元数据 // 使用内置的 WeakMap 存储元数据
@@ -136,6 +139,7 @@ export class ApiController {
最终的API路径 = 全局前缀 + 控制器前缀 + 方法路径 最终的API路径 = 全局前缀 + 控制器前缀 + 方法路径
例如: 例如:
- 全局前缀:`/api` - 全局前缀:`/api`
- 控制器前缀:`/user` - 控制器前缀:`/user`
- 方法路径:`/list` - 方法路径:`/list`
@@ -176,56 +180,11 @@ async getUser(ctx: Context) {
## 现有路由 ## 现有路由
项目中已注册的路由:
### ApplicationController
- `GET /api/application/list` - 获取应用列表
- `GET /api/application/detail/:id` - 获取应用详情
### UserController ### UserController
- `GET /api/user/list` - 获取用户列表 - `GET /api/user/list` - 获取用户列表
- `GET /api/user/detail/:id` - 获取用户详情 - `GET /api/user/detail/:id` - 获取用户详情
- `POST /api/user` - 创建用户 - `POST /api/user` - 创建用户
- `PUT /api/user/:id` - 更新用户 - `PUT /api/user/:id` - 更新用户
- `DELETE /api/user/:id` - 删除用户 - `DELETE /api/user/:id` - 删除用户
- `GET /api/user/search` - 搜索用户 - `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` 进行初始化,性能更优

View File

@@ -1,8 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
export const listDeploymentsQuerySchema = z.object({ export const listDeploymentsQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1), page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional().default(10), pageSize: z.coerce.number().int().min(1).max(100).optional(),
projectId: z.coerce.number().int().positive().optional(), projectId: z.coerce.number().int().positive().optional(),
}); });

View File

@@ -20,24 +20,30 @@ export class DeploymentController {
where.projectId = projectId; where.projectId = projectId;
} }
const isPagination = page !== undefined && pageSize !== undefined;
const result = await prisma.deployment.findMany({ const result = await prisma.deployment.findMany({
where, where,
take: pageSize, take: isPagination ? pageSize : undefined,
skip: (page - 1) * pageSize, skip: isPagination ? (page! - 1) * pageSize! : 0,
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}); });
const total = await prisma.deployment.count({ where }); const total = await prisma.deployment.count({ where });
if (isPagination) {
return { return {
data: result, list: result,
page, page,
pageSize, pageSize,
total, total,
}; };
} }
return result;
}
@Post('') @Post('')
async create(ctx: Context) { async create(ctx: Context) {
const body = createDeploymentSchema.parse(ctx.request.body); const body = createDeploymentSchema.parse(ctx.request.body);

View File

@@ -66,19 +66,8 @@ export const updateProjectSchema = z.object({
*/ */
export const listProjectQuerySchema = z export const listProjectQuerySchema = z
.object({ .object({
page: z.coerce page: z.coerce.number().int().min(1).optional(),
.number() pageSize: z.coerce.number().int().min(1).max(100).optional(),
.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(), name: z.string().optional(),
}) })
.optional(); .optional();

View File

@@ -29,29 +29,32 @@ export class ProjectController {
}; };
} }
const isPagination = query?.page !== undefined && query?.pageSize !== undefined;
const [total, projects] = await Promise.all([ const [total, projects] = await Promise.all([
prisma.project.count({ where: whereCondition }), prisma.project.count({ where: whereCondition }),
prisma.project.findMany({ prisma.project.findMany({
where: whereCondition, where: whereCondition,
skip: query ? (query.page - 1) * query.limit : 0, skip: isPagination ? (query.page! - 1) * query.pageSize! : 0,
take: query?.limit, take: isPagination ? query.pageSize : undefined,
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}), }),
]); ]);
if (isPagination) {
return { return {
data: projects, list: projects,
pagination: { page: query.page,
page: query?.page || 1, pageSize: query.pageSize,
limit: query?.limit || 10,
total, total,
totalPages: Math.ceil(total / (query?.limit || 10)),
},
}; };
} }
return projects;
}
// GET /api/projects/:id - 获取单个项目 // GET /api/projects/:id - 获取单个项目
@Get(':id') @Get(':id')
async show(ctx: Context) { async show(ctx: Context) {

View File

@@ -84,15 +84,13 @@ export const listStepsQuerySchema = z
.number() .number()
.int() .int()
.min(1, { message: '页码必须大于0' }) .min(1, { message: '页码必须大于0' })
.optional() .optional(),
.default(1), pageSize: z.coerce
limit: z.coerce
.number() .number()
.int() .int()
.min(1, { message: '每页数量必须大于0' }) .min(1, { message: '每页数量必须大于0' })
.max(100, { message: '每页数量不能超过100' }) .max(100, { message: '每页数量不能超过100' })
.optional() .optional(),
.default(10),
}) })
.optional(); .optional();

View File

@@ -26,29 +26,32 @@ export class StepController {
whereCondition.pipelineId = query.pipelineId; whereCondition.pipelineId = query.pipelineId;
} }
const isPagination = query?.page !== undefined && query?.pageSize !== undefined;
const [total, steps] = await Promise.all([ const [total, steps] = await Promise.all([
prisma.step.count({ where: whereCondition }), prisma.step.count({ where: whereCondition }),
prisma.step.findMany({ prisma.step.findMany({
where: whereCondition, where: whereCondition,
skip: query ? (query.page - 1) * query.limit : 0, skip: isPagination ? (query.page! - 1) * query.pageSize! : 0,
take: query?.limit, take: isPagination ? query.pageSize : undefined,
orderBy: { orderBy: {
order: 'asc', order: 'asc',
}, },
}), }),
]); ]);
if (isPagination) {
return { return {
data: steps, list: steps,
pagination: { page: query.page,
page: query?.page || 1, pageSize: query.pageSize,
limit: query?.limit || 10,
total, total,
totalPages: Math.ceil(total / (query?.limit || 10)),
},
}; };
} }
return steps;
}
// GET /api/steps/:id - 获取单个步骤 // GET /api/steps/:id - 获取单个步骤
@Get(':id') @Get(':id')
async show(ctx: Context) { async show(ctx: Context) {

View File

@@ -118,11 +118,6 @@ export class UserController {
results = results.filter((user) => user.status === status); results = results.filter((user) => user.status === status);
} }
return { return results;
keyword,
status,
total: results.length,
results,
};
} }
} }

Binary file not shown.

View File

@@ -2,7 +2,7 @@ import type React from 'react';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
export function useAsyncEffect( export function useAsyncEffect(
effect: () => Promise<undefined | (() => void)>, effect: () => Promise<any | (() => void)>,
deps: React.DependencyList, deps: React.DependencyList,
) { ) {
const callback = useCallback(effect, [...deps]); const callback = useCallback(effect, [...deps]);

View File

@@ -1,4 +1,4 @@
import Env from '@pages/env';
import Home from '@pages/home'; import Home from '@pages/home';
import Login from '@pages/login'; import Login from '@pages/login';
import ProjectDetail from '@pages/project/detail'; import ProjectDetail from '@pages/project/detail';
@@ -13,7 +13,7 @@ const App = () => {
<Route index element={<Navigate to="project" replace />} /> <Route index element={<Navigate to="project" replace />} />
<Route path="project" element={<ProjectList />} /> <Route path="project" element={<ProjectList />} />
<Route path="project/:id" element={<ProjectDetail />} /> <Route path="project/:id" element={<ProjectDetail />} />
<Route path="env" element={<Env />} />
</Route> </Route>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
</Routes> </Routes>

View File

@@ -1,5 +0,0 @@
function Env() {
return <div>env page</div>;
}
export default Env;

View File

@@ -4,7 +4,7 @@ import {
IconExport, IconExport,
IconMenuFold, IconMenuFold,
IconMenuUnfold, IconMenuUnfold,
IconRobot,
} from '@arco-design/web-react/icon'; } from '@arco-design/web-react/icon';
import Logo from '@assets/images/logo.svg?react'; import Logo from '@assets/images/logo.svg?react';
import { loginService } from '@pages/login/service'; import { loginService } from '@pages/login/service';
@@ -45,12 +45,7 @@ function Home() {
<span></span> <span></span>
</Link> </Link>
</Menu.Item> </Menu.Item>
<Menu.Item key="1">
<Link to="/env">
<IconRobot fontSize={16} />
</Link>
</Menu.Item>
</Menu> </Menu>
</Layout.Sider> </Layout.Sider>
<Layout> <Layout>

View File

@@ -2,11 +2,11 @@ import { Message, Notification } from '@arco-design/web-react';
import { net } from '../../utils'; import { net } from '../../utils';
import type { NavigateFunction } from 'react-router'; import type { NavigateFunction } from 'react-router';
import { useGlobalStore } from '../../stores/global'; import { useGlobalStore } from '../../stores/global';
import type { AuthLoginResponse, AuthURLResponse } from './types'; import type { AuthURL, User } from './types';
class LoginService { class LoginService {
async getAuthUrl() { async getAuthUrl() {
const { code, data } = await net.request<AuthURLResponse>({ const { code, data } = await net.request<AuthURL>({
method: 'GET', method: 'GET',
url: '/api/auth/url', url: '/api/auth/url',
params: { params: {
@@ -19,7 +19,7 @@ class LoginService {
} }
async login(authCode: string, navigate: NavigateFunction) { async login(authCode: string, navigate: NavigateFunction) {
const { data, code } = await net.request<AuthLoginResponse>({ const { data, code } = await net.request<User>({
method: 'POST', method: 'POST',
url: '/api/auth/login', url: '/api/auth/login',
data: { data: {
@@ -37,7 +37,7 @@ class LoginService {
} }
async logout() { async logout() {
const { code } = await net.request<AuthURLResponse>({ const { code } = await net.request<null>({
method: 'GET', method: 'GET',
url: '/api/auth/logout', url: '/api/auth/logout',
}); });

View File

@@ -1,5 +1,3 @@
import type { APIResponse } from '../../utils';
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
@@ -8,8 +6,6 @@ export interface User {
active: boolean; active: boolean;
} }
export type AuthURLResponse = APIResponse<{ export interface AuthURL {
url: string; url: string;
}>; };
export type AuthLoginResponse = APIResponse<User>;

View File

@@ -10,6 +10,7 @@ import {
Menu, Menu,
Message, Message,
Modal, Modal,
Pagination,
Select, Select,
Space, Space,
Switch, Switch,
@@ -94,6 +95,11 @@ function ProjectDetailPage() {
const [pipelineForm] = Form.useForm(); const [pipelineForm] = Form.useForm();
const [deployRecords, setDeployRecords] = useState<Deployment[]>([]); const [deployRecords, setDeployRecords] = useState<Deployment[]>([]);
const [deployModalVisible, setDeployModalVisible] = useState(false); const [deployModalVisible, setDeployModalVisible] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
// 流水线模板相关状态 // 流水线模板相关状态
const [isCreatingFromTemplate, setIsCreatingFromTemplate] = useState(false); const [isCreatingFromTemplate, setIsCreatingFromTemplate] = useState(false);
@@ -153,10 +159,15 @@ function ProjectDetailPage() {
// 获取部署记录 // 获取部署记录
try { try {
const records = await detailService.getDeployments(Number(id)); const res = await detailService.getDeployments(
setDeployRecords(records); Number(id),
if (records.length > 0) { 1,
setSelectedRecordId(records[0].id); pagination.pageSize,
);
setDeployRecords(res.list);
setPagination((prev) => ({ ...prev, total: res.total, current: 1 }));
if (res.list.length > 0) {
setSelectedRecordId(res.list[0].id);
} }
} catch (error) { } catch (error) {
console.error('获取部署记录失败:', error); console.error('获取部署记录失败:', error);
@@ -175,15 +186,21 @@ function ProjectDetailPage() {
}; };
// 定期轮询部署记录以更新状态和日志 // 定期轮询部署记录以更新状态和日志
useAsyncEffect(async () => { useEffect(() => {
const interval = setInterval(async () => { if (!id) return;
if (id) {
const poll = async () => {
try { try {
const records = await detailService.getDeployments(Number(id)); const res = await detailService.getDeployments(
setDeployRecords(records); Number(id),
pagination.current,
pagination.pageSize,
);
setDeployRecords(res.list);
setPagination((prev) => ({ ...prev, total: res.total }));
// 如果当前选中的记录正在运行,则更新选中记录 // 如果当前选中的记录正在运行,则更新选中记录
const selectedRecord = records.find( const selectedRecord = res.list.find(
(r: Deployment) => r.id === selectedRecordId, (r: Deployment) => r.id === selectedRecordId,
); );
if ( if (
@@ -196,11 +213,13 @@ function ProjectDetailPage() {
} catch (error) { } catch (error) {
console.error('轮询部署记录失败:', error); console.error('轮询部署记录失败:', error);
} }
} };
}, 3000); // 每3秒轮询一次
poll(); // 立即执行一次
const interval = setInterval(poll, 3000); // 每3秒轮询一次
return () => clearInterval(interval); return () => clearInterval(interval);
}, [id, selectedRecordId]); }, [id, selectedRecordId, pagination.current, pagination.pageSize]);
// 触发部署 // 触发部署
const handleDeploy = () => { const handleDeploy = () => {
@@ -601,8 +620,13 @@ function ProjectDetailPage() {
// 刷新部署记录 // 刷新部署记录
if (id) { if (id) {
const records = await detailService.getDeployments(Number(id)); const res = await detailService.getDeployments(
setDeployRecords(records); Number(id),
pagination.current,
pagination.pageSize,
);
setDeployRecords(res.list);
setPagination((prev) => ({ ...prev, total: res.total }));
} }
} catch (error) { } catch (error) {
console.error('重新执行部署失败:', error); console.error('重新执行部署失败:', error);
@@ -841,7 +865,8 @@ function ProjectDetailPage() {
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0 flex flex-col">
<div className="flex-1 overflow-y-auto">
{deployRecords.length > 0 ? ( {deployRecords.length > 0 ? (
<List <List
className="bg-white rounded-lg border" className="bg-white rounded-lg border"
@@ -855,6 +880,19 @@ function ProjectDetailPage() {
</div> </div>
)} )}
</div> </div>
<div className="mt-2 text-right">
<Pagination
total={pagination.total}
current={pagination.current}
pageSize={pagination.pageSize}
size="small"
simple
onChange={(page) =>
setPagination((prev) => ({ ...prev, current: page }))
}
/>
</div>
</div>
</div> </div>
{/* 右侧构建日志 */} {/* 右侧构建日志 */}
@@ -1383,14 +1421,7 @@ function ProjectDetailPage() {
style={{ fontFamily: 'Monaco, Consolas, monospace' }} style={{ fontFamily: 'Monaco, Consolas, monospace' }}
/> />
</Form.Item> </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> </Form>
</Modal> </Modal>
@@ -1401,10 +1432,17 @@ function ProjectDetailPage() {
setDeployModalVisible(false); setDeployModalVisible(false);
// 刷新部署记录 // 刷新部署记录
if (id) { if (id) {
detailService.getDeployments(Number(id)).then((records) => { detailService
setDeployRecords(records); .getDeployments(Number(id), 1, pagination.pageSize)
if (records.length > 0) { .then((res) => {
setSelectedRecordId(records[0].id); setDeployRecords(res.list);
setPagination((prev) => ({
...prev,
total: res.total,
current: 1,
}));
if (res.list.length > 0) {
setSelectedRecordId(res.list[0].id);
} }
}); });
} }

View File

@@ -1,4 +1,4 @@
import { type APIResponse, net } from '../../../utils'; import { net } from '../../../utils';
import type { import type {
Branch, Branch,
Commit, Commit,
@@ -11,7 +11,7 @@ import type {
class DetailService { class DetailService {
async getProject(id: string) { async getProject(id: string) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
}); });
return data; return data;
@@ -19,28 +19,32 @@ class DetailService {
// 获取项目的所有流水线 // 获取项目的所有流水线
async getPipelines(projectId: number) { async getPipelines(projectId: number) {
const { data } = await net.request<APIResponse<Pipeline[]>>({ const { data } = await net.request<Pipeline[] | { list: Pipeline[] }>({
url: `/api/pipelines?projectId=${projectId}`, url: `/api/pipelines?projectId=${projectId}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 获取可用的流水线模板 // 获取可用的流水线模板
async getPipelineTemplates() { async getPipelineTemplates() {
const { data } = await net.request< const { data } = await net.request<
APIResponse<{ id: number; name: string; description: string }[]> | { id: number; name: string; description: string }[]
| { list: { id: number; name: string; description: string }[] }
>({ >({
url: '/api/pipelines/templates', url: '/api/pipelines/templates',
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 获取项目的部署记录 async getDeployments(
async getDeployments(projectId: number) { projectId: number,
const { data } = await net.request<any>({ page: number = 1,
url: `/api/deployments?projectId=${projectId}`, pageSize: number = 10,
) {
const { data } = await net.request<DeploymentListResponse>({
url: `/api/deployments?projectId=${projectId}&page=${page}&pageSize=${pageSize}`,
}); });
return data.data; return data;
} }
// 创建流水线 // 创建流水线
@@ -56,7 +60,7 @@ class DetailService {
| 'steps' | 'steps'
>, >,
) { ) {
const { data } = await net.request<APIResponse<Pipeline>>({ const { data } = await net.request<Pipeline>({
url: '/api/pipelines', url: '/api/pipelines',
method: 'POST', method: 'POST',
data: pipeline, data: pipeline,
@@ -71,7 +75,7 @@ class DetailService {
name: string, name: string,
description?: string, description?: string,
) { ) {
const { data } = await net.request<APIResponse<Pipeline>>({ const { data } = await net.request<Pipeline>({
url: '/api/pipelines/from-template', url: '/api/pipelines/from-template',
method: 'POST', method: 'POST',
data: { data: {
@@ -100,7 +104,7 @@ class DetailService {
> >
>, >,
) { ) {
const { data } = await net.request<APIResponse<Pipeline>>({ const { data } = await net.request<Pipeline>({
url: `/api/pipelines/${id}`, url: `/api/pipelines/${id}`,
method: 'PUT', method: 'PUT',
data: pipeline, data: pipeline,
@@ -110,7 +114,7 @@ class DetailService {
// 删除流水线 // 删除流水线
async deletePipeline(id: number) { async deletePipeline(id: number) {
const { data } = await net.request<APIResponse<null>>({ const { data } = await net.request<null>({
url: `/api/pipelines/${id}`, url: `/api/pipelines/${id}`,
method: 'DELETE', method: 'DELETE',
}); });
@@ -119,10 +123,10 @@ class DetailService {
// 获取流水线的所有步骤 // 获取流水线的所有步骤
async getSteps(pipelineId: number) { async getSteps(pipelineId: number) {
const { data } = await net.request<APIResponse<Step[]>>({ const { data } = await net.request<Step[] | { list: Step[] }>({
url: `/api/steps?pipelineId=${pipelineId}`, url: `/api/steps?pipelineId=${pipelineId}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 创建步骤 // 创建步骤
@@ -132,7 +136,7 @@ class DetailService {
'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid' 'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid'
>, >,
) { ) {
const { data } = await net.request<APIResponse<Step>>({ const { data } = await net.request<Step>({
url: '/api/steps', url: '/api/steps',
method: 'POST', method: 'POST',
data: step, data: step,
@@ -150,7 +154,7 @@ class DetailService {
> >
>, >,
) { ) {
const { data } = await net.request<APIResponse<Step>>({ const { data } = await net.request<Step>({
url: `/api/steps/${id}`, url: `/api/steps/${id}`,
method: 'PUT', method: 'PUT',
data: step, data: step,
@@ -161,7 +165,7 @@ class DetailService {
// 删除步骤 // 删除步骤
async deleteStep(id: number) { async deleteStep(id: number) {
// DELETE请求返回204状态码通过拦截器处理为成功响应 // DELETE请求返回204状态码通过拦截器处理为成功响应
const { data } = await net.request<APIResponse<null>>({ const { data } = await net.request<null>({
url: `/api/steps/${id}`, url: `/api/steps/${id}`,
method: 'DELETE', method: 'DELETE',
}); });
@@ -170,23 +174,23 @@ class DetailService {
// 获取项目的提交记录 // 获取项目的提交记录
async getCommits(projectId: number, branch?: string) { async getCommits(projectId: number, branch?: string) {
const { data } = await net.request<APIResponse<Commit[]>>({ const { data } = await net.request<Commit[] | { list: Commit[] }>({
url: `/api/git/commits?projectId=${projectId}${branch ? `&branch=${branch}` : ''}`, url: `/api/git/commits?projectId=${projectId}${branch ? `&branch=${branch}` : ''}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 获取项目的分支列表 // 获取项目的分支列表
async getBranches(projectId: number) { async getBranches(projectId: number) {
const { data } = await net.request<APIResponse<Branch[]>>({ const { data } = await net.request<Branch[] | { list: Branch[] }>({
url: `/api/git/branches?projectId=${projectId}`, url: `/api/git/branches?projectId=${projectId}`,
}); });
return data; return Array.isArray(data) ? data : data.list;
} }
// 创建部署 // 创建部署
async createDeployment(deployment: CreateDeploymentRequest) { async createDeployment(deployment: CreateDeploymentRequest) {
const { data } = await net.request<APIResponse<Deployment>>({ const { data } = await net.request<Deployment>({
url: '/api/deployments', url: '/api/deployments',
method: 'POST', method: 'POST',
data: deployment, data: deployment,
@@ -196,7 +200,7 @@ class DetailService {
// 重新执行部署 // 重新执行部署
async retryDeployment(deploymentId: number) { async retryDeployment(deploymentId: number) {
const { data } = await net.request<APIResponse<Deployment>>({ const { data } = await net.request<Deployment>({
url: `/api/deployments/${deploymentId}/retry`, url: `/api/deployments/${deploymentId}/retry`,
method: 'POST', method: 'POST',
}); });
@@ -205,7 +209,7 @@ class DetailService {
// 获取项目详情(包含工作目录状态) // 获取项目详情(包含工作目录状态)
async getProjectDetail(id: number) { async getProjectDetail(id: number) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
}); });
return data; return data;
@@ -213,7 +217,7 @@ class DetailService {
// 更新项目 // 更新项目
async updateProject(id: number, project: Partial<Project>) { async updateProject(id: number, project: Partial<Project>) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
method: 'PUT', method: 'PUT',
data: project, data: project,
@@ -231,3 +235,10 @@ class DetailService {
} }
export const detailService = new DetailService(); export const detailService = new DetailService();
export interface DeploymentListResponse {
list: Deployment[];
page: number;
pageSize: number;
total: number;
}

View File

@@ -15,7 +15,7 @@ function ProjectPage() {
useAsyncEffect(async () => { useAsyncEffect(async () => {
const response = await projectService.list(); const response = await projectService.list();
setProjects(response.data); setProjects(response.list);
}, []); }, []);
const handleCreateProject = () => { const handleCreateProject = () => {

View File

@@ -1,18 +1,20 @@
import { type APIResponse, net } from '../../../utils'; import { net } from '../../../utils';
import type { Project } from '../types'; import type { Project } from '../types';
class ProjectService { class ProjectService {
async list(params?: ProjectQueryParams) { async list(params?: ProjectQueryParams) {
const { data } = await net.request<APIResponse<ProjectListResponse>>({ const { data } = await net.request<Project[] | ProjectListResponse>({
method: 'GET', method: 'GET',
url: '/api/projects', url: '/api/projects',
params, params,
}); });
return data; return Array.isArray(data)
? { list: data, page: 1, pageSize: data.length, total: data.length }
: data;
} }
async show(id: string) { async show(id: string) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
method: 'GET', method: 'GET',
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
}); });
@@ -24,7 +26,7 @@ class ProjectService {
description?: string; description?: string;
repository: string; repository: string;
}) { }) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
method: 'POST', method: 'POST',
url: '/api/projects', url: '/api/projects',
data: project, data: project,
@@ -36,7 +38,7 @@ class ProjectService {
id: string, id: string,
project: Partial<{ name: string; description: string; repository: string }>, project: Partial<{ name: string; description: string; repository: string }>,
) { ) {
const { data } = await net.request<APIResponse<Project>>({ const { data } = await net.request<Project>({
method: 'PUT', method: 'PUT',
url: `/api/projects/${id}`, url: `/api/projects/${id}`,
data: project, data: project,
@@ -56,17 +58,14 @@ class ProjectService {
export const projectService = new ProjectService(); export const projectService = new ProjectService();
interface ProjectListResponse { interface ProjectListResponse {
data: Project[]; list: Project[];
pagination: {
page: number; page: number;
limit: number; pageSize: number;
total: number; total: number;
totalPages: number;
};
} }
interface ProjectQueryParams { interface ProjectQueryParams {
page?: number; page?: number;
limit?: number; pageSize?: number;
name?: string; name?: string;
} }

View File

@@ -1,25 +1,13 @@
import { type APIResponse, net } from '../utils'; import { net } from '@utils';
import { create } from 'zustand'; import { create } from 'zustand';
import type { GlobalStore } from './types';
interface User { import type { User } from '@pages/login/types';
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) => ({ export const useGlobalStore = create<GlobalStore>((set) => ({
user: null, user: null,
setUser: (user: User) => set({ user }), setUser: (user: User) => set({ user }),
async refreshUser() { async refreshUser() {
const { data } = await net.request<APIResponse<User>>({ const { data } = await net.request<User>({
method: 'GET', method: 'GET',
url: '/api/auth/info', url: '/api/auth/info',
}); });

View File

@@ -0,0 +1,13 @@
interface User {
id: string;
username: string;
email: string;
avatar_url: string;
active: boolean;
}
export interface GlobalStore {
user: User | null;
setUser: (user: User) => void;
refreshUser: () => Promise<void>;
}

View File

@@ -42,9 +42,9 @@ class Net {
); );
} }
async request<T>(config: AxiosRequestConfig): Promise<T> { async request<T>(config: AxiosRequestConfig): Promise<APIResponse<T>> {
try { try {
const response = await this.instance.request<T>(config); const response = await this.instance.request<APIResponse<T>>(config);
if (!response || !response.data) { if (!response || !response.data) {
throw new Error('Invalid response'); throw new Error('Invalid response');
} }
@@ -56,6 +56,13 @@ class Net {
} }
} }
export interface APIPagination<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
export interface APIResponse<T> { export interface APIResponse<T> {
code: number; code: number;
data: T; data: T;

View File

@@ -24,7 +24,8 @@
"@pages/*": ["./src/pages/*"], "@pages/*": ["./src/pages/*"],
"@styles/*": ["./src/styles/*"], "@styles/*": ["./src/styles/*"],
"@assets/*": ["./src/assets/*"], "@assets/*": ["./src/assets/*"],
"@utils/*": ["./src/utils/*"] "@utils/*": ["./src/utils/*"],
"@utils": ["./src/utils"]
} }
}, },
"include": ["src"] "include": ["src"]

View File

@@ -32,16 +32,48 @@ web 项目代码组织如下:
- 注释符合 jsdoc 规范 - 注释符合 jsdoc 规范
- 代码简洁避免冗余移除无用的代码引用、变量、函数和css样式 - 代码简洁避免冗余移除无用的代码引用、变量、函数和css样式
- 禁止使用 any 类型
## 4. 响应格式 ## 4. 前端发送net请求示例
- 后端统一返回 `APIResponse<T>` 结构: - 分页
```json ```typescript
{ "code": 0, "data": {}, "message": "success", "timestamp": 12345678 } import {net} from '@utils';
import type {APIPagination} from '@utils/net';
const data = await net.request<APIPagination<Deployment>>({
method: 'GET',
url: '/api/deployments',
// 注意:查询参数使用 params 传递,不要手动拼接到 url 上
params: {
projectId: 1,
page: 1,
pageSize: 10,
},
})
``` ```
- 由 `RouteScanner` 中的 `wrapControllerMethod` 自动封装。 - 其他
```typescript
import {net} from '@utils';
const data = await net.request<void>({
method: 'POST',
url: '/api/deployment',
data: {
name: 'xxx',
description: 'xxx',
repository: 'https://a.com',
}
})
if (data.code === 0) {
console.log("创建成功")
} else {
console.log("创建失败")
}
```
## 5. 异步处理 ## 5. 异步处理

View File

@@ -0,0 +1,33 @@
# 标准化响应结构
1. 需要标准化接口响应的结构,修改后端接口中不符合规范的代码。
2. 前端接口类型定义需要与后端接口响应结构保持一致。
## 响应示例
- 列表分页响应
```json
{
"code": 0, // 响应状态码0 表示成功,其他值表示失败
"message": "success", // 响应消息
"data": { // 响应数据
"list": [], // 列表数据
"page": 1, // 当前页码
"pageSize": 10, // 每页显示数量
"total": 10 // 总数量
},
"timestamp": "12346579" // 响应时间戳
}
```
- 其他响应
```json
{
"code": 0,
"message": "success",
"data": {},
"timestamp": "12346579"
}
```

View File

@@ -7,15 +7,17 @@
- 项目管理 (CRUD) - 项目管理 (CRUD)
- 基础流水线执行流程 (Git Clone -> zx Run -> Log Update) - 基础流水线执行流程 (Git Clone -> zx Run -> Log Update)
- 前端项目列表与详情页预览 - 前端项目列表与详情页预览
- 优化: 移除菜单环境管理及页面(目前无用)
- 优化:移除创建 Step 弹窗可用环境变量区域
- 优化: 部署记录的分页查询每页 10 条
## 进行中 🚧 ## 进行中 🚧
- 优化: 移除菜单环境管理及页面(目前无用) - [标准化接口响应结构](./requirements/0001-Fix-Response-structure.md)
- 优化: 部署记录的分页查询
- 修复: 表单必填项,*号和 label 不在一行
- 修复:项目详情页,未选中 tab【部署记录】还会拉取日志信息
## 待办 📅 ## 待办 📅
- [ ] Gitea Webhook 自动触发 - [ ] Gitea Webhook 自动触发
- [ ] 用户权限管理 (RBAC) - [ ] 用户权限管理 (RBAC)
- [ ] 修复: 表单必填项,*号和 label 不在一行
- [ ] 修复:项目详情页,未选中 tab【部署记录】还会拉取日志信息