feat: project list

This commit is contained in:
2025-09-06 01:44:33 +08:00
parent ef473d6084
commit 9b54d18ef3
11 changed files with 333 additions and 56 deletions

View File

@@ -20,7 +20,7 @@ export class AuthController {
if (!ctx.session.isNew) {
return ctx.session.user;
}
const { code } = (ctx.request as any).body;
const { code } = ctx.request.body as LoginRequestBody;
const { access_token } = await gitea.getToken(code);
const giteaUser = await gitea.getUserInfo(access_token);
log.debug(this.TAG, 'gitea user: %o', giteaUser);
@@ -64,3 +64,7 @@ export class AuthController {
return ctx.session.user;
}
}
interface LoginRequestBody {
code: string;
}

View File

@@ -4,8 +4,8 @@ import { log } from '../libs/logger.ts';
import { BusinessError } from '../middlewares/exception.ts';
import { Controller, Get } from '../decorators/route.ts';
@Controller('/application')
export class ApplicationController {
@Controller('/project')
export class ProjectController {
@Get('/list')
async list(ctx: Context) {
log.debug('app', 'session %o', ctx.session);

View File

@@ -1,3 +1,4 @@
import bodyParser from 'koa-bodyparser';
import type Koa from 'koa';
import type { Middleware } from './types.ts';
@@ -6,41 +7,6 @@ import type { Middleware } from './types.ts';
*/
export class BodyParser implements Middleware {
apply(app: Koa): void {
// 使用动态导入来避免类型问题
app.use(async (ctx, next) => {
if (ctx.request.method === 'POST' ||
ctx.request.method === 'PUT' ||
ctx.request.method === 'PATCH') {
// 简单的JSON解析
if (ctx.request.type === 'application/json') {
try {
const chunks: Buffer[] = [];
ctx.req.on('data', (chunk) => {
chunks.push(chunk);
});
await new Promise((resolve) => {
ctx.req.on('end', () => {
const body = Buffer.concat(chunks).toString();
try {
(ctx.request as any).body = JSON.parse(body);
} catch {
(ctx.request as any).body = {};
}
resolve(void 0);
});
});
} catch (error) {
(ctx.request as any).body = {};
}
} else {
(ctx.request as any).body = {};
}
}
await next();
});
app.use(bodyParser());
}
}

View File

@@ -2,7 +2,7 @@ import KoaRouter from '@koa/router';
import type Koa from 'koa';
import type { Middleware } from './types.ts';
import { RouteScanner } from '../libs/route-scanner.ts';
import { ApplicationController } from '../controllers/application.ts';
import { ProjectController } from '../controllers/project.ts';
import { UserController } from '../controllers/user.ts';
import { AuthController } from '../controllers/auth.ts';
import { log } from '../libs/logger.ts';
@@ -33,7 +33,7 @@ export class Router implements Middleware {
private registerDecoratorRoutes(): void {
// 注册所有使用装饰器的控制器
this.routeScanner.registerControllers([
ApplicationController,
ProjectController,
UserController,
AuthController,
]);

View File

@@ -14,6 +14,7 @@
"@koa/router": "^14.0.0",
"@prisma/client": "^6.15.0",
"koa": "^3.0.1",
"koa-bodyparser": "^4.4.1",
"koa-session": "^7.0.2",
"pino": "^9.9.1",
"pino-pretty": "^13.1.1"
@@ -22,6 +23,7 @@
"@tsconfig/node-ts": "^23.6.1",
"@tsconfig/node22": "^22.0.2",
"@types/koa": "^3.0.0",
"@types/koa-bodyparser": "^4.3.12",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4",
"@types/node": "^24.3.0",

View File

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

View File

@@ -1,12 +1,9 @@
import { Avatar, Layout, Menu } from '@arco-design/web-react';
import {
IconApps,
IconBulb,
IconFire,
IconMenuFold,
IconMenuUnfold,
IconRobot,
IconSafe,
IconUser,
} from '@arco-design/web-react/icon';
import { useState } from 'react';
@@ -21,9 +18,15 @@ function Home() {
<Layout.Sider
collapsible
onCollapse={setCollapsed}
trigger={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
trigger={
collapsed ? (
<IconMenuUnfold fontSize={16} />
) : (
<IconMenuFold fontSize={16} />
)
}
>
<div className="flex flex-row items-center justify-center px-2 py-3">
<div className="flex flex-row items-center justify-center h-[56px]">
<Logo />
{!collapsed && <h2 className="ml-4 text-xl font-medium">Foka CI</h2>}
</div>
@@ -31,16 +34,17 @@ function Home() {
className="flex-1"
defaultOpenKeys={['0']}
defaultSelectedKeys={['0_1']}
collapse={collapsed}
>
<Menu.Item key="0">
<Link to="/project" className="flex flex-row items-center">
<IconApps fontSize={18} />
<Link to="/project">
<IconApps fontSize={16} />
<span></span>
</Link>
</Menu.Item>
<Menu.Item key="1">
<Link to="/env" className="flex flex-row items-center">
<IconRobot fontSize={18} />
<Link to="/env">
<IconRobot fontSize={16} />
</Link>
</Menu.Item>

View File

@@ -1,8 +1,114 @@
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 { 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'
function Project() {
const [projects, setProjects] = useState([]);
return <div>project page</div>;
const { Text, Paragraph } = Typography;
function ProjectPage() {
const [projects, setProjects] = useState<Project[]>([]);
useAsyncEffect(async () => {
const list = await projectService.list();
setProjects(list);
}, []);
return (
<div className="p-6 bg-gray-100 min-h-screen">
<div className="mb-6">
<Typography.Title heading={2} className="!m-0 !text-gray-900">
</Typography.Title>
<Text type="secondary"></Text>
</div>
<Grid.Row gutter={[16, 16]}>
{projects.map((project) => (
<Grid.Col key={project.id} span={8}>
<Card
className="foka-card !rounded-xl border border-gray-200 h-[280px] hover:border-blue-200"
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>
<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.Row>
</div>
);
}
export default Project;
export default ProjectPage;

View File

@@ -0,0 +1,16 @@
import { net, type APIResponse } from "@shared";
import type { Project } from "./types";
class ProjectService {
async list() {
const { data } = await net.request<APIResponse<Project[]>>({
method: 'GET',
url: '/api/project/list',
})
return data;
}
}
export const projectService = new ProjectService();

View File

@@ -8,7 +8,7 @@ export interface Project {
id: string;
name: string;
description: string;
git: string;
repository: string;
env: Record<string, string>;
createdAt: string;
status: BuildStatus;