Browse Source

Merge branch 'starter' of 139-template-116/d8d-vite-starter into starter

18617351030 5 months ago
parent
commit
59f1ff3b73

+ 3 - 1
src/client/admin/pages/Users.tsx

@@ -5,10 +5,12 @@ import {
 } from 'antd';
 } from 'antd';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import dayjs from 'dayjs';
 import dayjs from 'dayjs';
-import { userClient } from '@/client/api';
+import { roleClient, userClient } from '@/client/api';
 import type { InferResponseType, InferRequestType } from 'hono/client';
 import type { InferResponseType, InferRequestType } from 'hono/client';
 
 
 type UserListResponse = InferResponseType<typeof userClient.$get, 200>;
 type UserListResponse = InferResponseType<typeof userClient.$get, 200>;
+type RoleListResponse = InferResponseType<typeof roleClient.$get, 200>;
+type CreateRoleRequest = InferRequestType<typeof roleClient.$post>['json'];
 type UserDetailResponse = InferResponseType<typeof userClient[':id']['$get'], 200>;
 type UserDetailResponse = InferResponseType<typeof userClient[':id']['$get'], 200>;
 type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
 type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
 type UpdateUserRequest = InferRequestType<typeof userClient[':id']['$put']>['json'];
 type UpdateUserRequest = InferRequestType<typeof userClient[':id']['$put']>['json'];

+ 5 - 1
src/client/api.ts

@@ -1,7 +1,7 @@
 import axios, { isAxiosError } from 'axios';
 import axios, { isAxiosError } from 'axios';
 import { hc } from 'hono/client'
 import { hc } from 'hono/client'
 import type {
 import type {
-  AuthRoutes, UserRoutes,
+  AuthRoutes, UserRoutes, RoleRoutes
 } from '@/server/api';
 } from '@/server/api';
 
 
 // 创建 axios 适配器
 // 创建 axios 适配器
@@ -60,3 +60,7 @@ export const authClient = hc<AuthRoutes>('/', {
 export const userClient = hc<UserRoutes>('/', {
 export const userClient = hc<UserRoutes>('/', {
   fetch: axiosFetch,
   fetch: axiosFetch,
 }).api.v1.users;
 }).api.v1.users;
+
+export const roleClient = hc<RoleRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1.roles;

+ 3 - 0
src/server/api.ts

@@ -2,6 +2,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'
 import { errorHandler } from './utils/errorHandler'
 import { errorHandler } from './utils/errorHandler'
 import usersRouter from './api/users/index'
 import usersRouter from './api/users/index'
 import authRoute from './api/auth/index'
 import authRoute from './api/auth/index'
+import rolesRoute from './api/roles/index'
 import { AuthContext } from './types/context'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { AppDataSource } from './data-source'
 
 
@@ -51,8 +52,10 @@ if(!import.meta.env.PROD){
 
 
 const userRoutes = api.route('/api/v1/users', usersRouter)
 const userRoutes = api.route('/api/v1/users', usersRouter)
 const authRoutes = api.route('/api/v1/auth', authRoute)
 const authRoutes = api.route('/api/v1/auth', authRoute)
+const roleRoutes = api.route('/api/v1/roles', rolesRoute)
 
 
 export type AuthRoutes = typeof authRoutes
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
 export type UserRoutes = typeof userRoutes
+export type RoleRoutes = typeof roleRoutes
 
 
 export default api
 export default api

+ 25 - 0
src/server/api/roles/index.ts

@@ -0,0 +1,25 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { Role } from '@/server/modules/users/role.entity';
+import { RoleSchema, CreateRoleDto, UpdateRoleDto } from '@/server/modules/users/role.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { checkPermission, permissionMiddleware } from '@/server/middleware/permission.middleware';
+import { OpenAPIHono } from '@hono/zod-openapi';
+
+// 创建角色CRUD路由
+const roleRoutes = createCrudRoutes({
+  entity: Role,
+  createSchema: CreateRoleDto,
+  updateSchema: UpdateRoleDto,
+  getSchema: RoleSchema,
+  listSchema: RoleSchema,
+  searchFields: ['name', 'description'],
+  middleware: [
+    authMiddleware, 
+    // permissionMiddleware(checkPermission(['role:manage']))
+]
+})
+const app = new OpenAPIHono()
+  .route('/', roleRoutes)
+// .route('/', customRoute)
+
+export default app;

+ 17 - 1
src/server/index.tsx

@@ -25,7 +25,23 @@ app.route('/', createApi)
 if(!import.meta.env.PROD){
 if(!import.meta.env.PROD){
   app.get('/ui', swaggerUI({
   app.get('/ui', swaggerUI({
     url: '/doc',
     url: '/doc',
-    persistAuthorization: true
+    persistAuthorization: true,
+    manuallySwaggerUIHtml: (asset) => `
+      <div>
+        <div id="swagger-ui"></div>
+        <link rel="stylesheet" href="https://ai-oss.d8d.fun/swagger-ui-dist/swagger-ui.css" />
+        <script src="https://ai-oss.d8d.fun/swagger-ui-dist/swagger-ui-bundle.js" crossorigin="anonymous"></script>
+        <script>
+          window.onload = () => {
+            window.ui = SwaggerUIBundle({
+              dom_id: '#swagger-ui',
+              url: '/doc',
+              persistAuthorization: true
+            })
+          }
+        </script>
+      </div>
+    `
   }))
   }))
 }
 }
 
 

+ 11 - 3
src/server/modules/users/role.entity.ts

@@ -1,4 +1,4 @@
-import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
 import { z } from '@hono/zod-openapi';
 import { z } from '@hono/zod-openapi';
 
 
 export type Permission = string;
 export type Permission = string;
@@ -19,10 +19,12 @@ export const RoleSchema = z.object({
   permissions: z.array(z.string()).min(1).openapi({
   permissions: z.array(z.string()).min(1).openapi({
     description: '角色权限列表',
     description: '角色权限列表',
     example: ['user:create', 'user:delete']
     example: ['user:create', 'user:delete']
-  })
+  }),
+  createdAt: z.date().openapi({ description: '创建时间' }),
+  updatedAt: z.date().openapi({ description: '更新时间' })
 });
 });
 
 
-export const CreateRoleDto = RoleSchema.omit({ id: true });
+export const CreateRoleDto = RoleSchema.omit({ id: true , createdAt: true, updatedAt: true });
 export const UpdateRoleDto = RoleSchema.partial();
 export const UpdateRoleDto = RoleSchema.partial();
 
 
 @Entity({ name: 'role' })
 @Entity({ name: 'role' })
@@ -39,6 +41,12 @@ export class Role {
   @Column({ type: 'simple-array', nullable: false })
   @Column({ type: 'simple-array', nullable: false })
   permissions: Permission[] = [];
   permissions: Permission[] = [];
 
 
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+
   constructor(partial?: Partial<Role>) {
   constructor(partial?: Partial<Role>) {
     Object.assign(this, partial);
     Object.assign(this, partial);
     if (!this.permissions) {
     if (!this.permissions) {

+ 20 - 0
src/server/modules/users/role.service.ts

@@ -0,0 +1,20 @@
+import { DataSource, Repository } from 'typeorm';
+import { Role } from './role.entity';
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+
+export class RoleService extends GenericCrudService<Role> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, Role);
+  }
+  
+  // 可以添加角色特有的业务逻辑方法
+  async getRoleByName(name: string): Promise<Role | null> {
+    return this.repository.findOneBy({ name });
+  }
+  
+  async checkPermission(roleId: number, permission: string): Promise<boolean> {
+    const role = await this.getById(roleId);
+    if (!role) return false;
+    return role.permissions.includes(permission);
+  }
+}

+ 3 - 1
src/server/modules/users/user.entity.ts

@@ -89,7 +89,9 @@ export const UserSchema = z.object({
     description: '是否删除(0:未删除,1:已删除)'
     description: '是否删除(0:未删除,1:已删除)'
   }),
   }),
   roles: z.array(RoleSchema).optional().openapi({
   roles: z.array(RoleSchema).optional().openapi({
-    example: [{ id: 1, name: 'admin',description:'管理员', permissions: ['user:create'] }],
+    example: [
+      { id: 1, name: 'admin',description:'管理员', permissions: ['user:create'] ,createdAt: new Date(), updatedAt: new Date() }
+    ],
     description: '用户角色列表'
     description: '用户角色列表'
   }),
   }),
   createdAt: z.date().openapi({ description: '创建时间' }),
   createdAt: z.date().openapi({ description: '创建时间' }),

+ 338 - 0
src/server/utils/generic-crud.routes.ts

@@ -0,0 +1,338 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { GenericCrudService, CrudOptions } from './generic-crud.service';
+import { ErrorSchema } from './errorHandler';
+import { AuthContext } from '../types/context';
+import { ObjectLiteral } from 'typeorm';
+import { AppDataSource } from '../data-source';
+
+export function createCrudRoutes<
+  T extends ObjectLiteral,
+  CreateSchema extends z.ZodSchema = z.ZodSchema,
+  UpdateSchema extends z.ZodSchema = z.ZodSchema,
+  GetSchema extends z.ZodSchema = z.ZodSchema,
+  ListSchema extends z.ZodSchema = z.ZodSchema
+>(options: CrudOptions<T, CreateSchema, UpdateSchema, GetSchema, ListSchema>) {
+  const { entity, createSchema, updateSchema, getSchema, listSchema, searchFields, middleware = [] } = options;
+  
+  // 创建CRUD服务实例
+  // 抽象类不能直接实例化,需要创建具体实现类
+  class ConcreteCrudService extends GenericCrudService<T> {
+    constructor() {
+      super(AppDataSource, entity);
+    }
+  }
+  const crudService = new ConcreteCrudService();
+  
+  // 创建路由实例
+  const app = new OpenAPIHono<AuthContext>();
+  
+  // 分页查询路由
+  const listRoute = createRoute({
+    method: 'get',
+    path: '/',
+    middleware,
+    request: {
+      query: z.object({
+        page: z.coerce.number().int().positive().default(1).openapi({
+          example: 1,
+          description: '页码,从1开始'
+        }),
+        pageSize: z.coerce.number().int().positive().default(10).openapi({
+          example: 10,
+          description: '每页数量'
+        }),
+        keyword: z.string().optional().openapi({
+          example: '搜索关键词',
+          description: '搜索关键词'
+        }),
+        sortBy: z.string().optional().openapi({
+          example: 'createdAt',
+          description: '排序字段'
+        }),
+        sortOrder: z.enum(['ASC', 'DESC']).optional().default('DESC').openapi({
+          example: 'DESC',
+          description: '排序方向'
+        })
+      })
+    },
+    responses: {
+      200: {
+        description: '成功获取列表',
+        content: {
+          'application/json': {
+            schema: z.object({
+              data: z.array(listSchema),
+              pagination: z.object({
+                total: z.number().openapi({ example: 100, description: '总记录数' }),
+                current: z.number().openapi({ example: 1, description: '当前页码' }),
+                pageSize: z.number().openapi({ example: 10, description: '每页数量' })
+              })
+            })
+          }
+        }
+      },
+      400: {
+        description: '参数错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 创建资源路由
+  const createRouteDef = createRoute({
+    method: 'post',
+    path: '/',
+    middleware,
+    request: {
+      body: {
+        content: {
+          'application/json': { schema: createSchema }
+        }
+      }
+    },
+    responses: {
+      201: {
+        description: '创建成功',
+        content: { 'application/json': { schema: getSchema } }
+      },
+      400: {
+        description: '输入数据无效',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 获取单个资源路由
+  const getRouteDef = createRoute({
+    method: 'get',
+    path: '/{id}',
+    middleware,
+    request: {
+      params: z.object({
+        id: z.coerce.number().openapi({
+          param: { name: 'id', in: 'path' },
+          example: 1,
+          description: '资源ID'
+        })
+      })
+    },
+    responses: {
+      200: {
+        description: '成功获取详情',
+        content: { 'application/json': { schema: getSchema } }
+      },
+      400: {
+        description: '资源不存在',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      404: {
+        description: '参数验证失败',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 更新资源路由
+  const updateRouteDef = createRoute({
+    method: 'put',
+    path: '/{id}',
+    middleware,
+    request: {
+      params: z.object({
+        id: z.coerce.number().openapi({
+          param: { name: 'id', in: 'path' },
+          example: 1,
+          description: '资源ID'
+        })
+      }),
+      body: {
+        content: {
+          'application/json': { schema: updateSchema }
+        }
+      }
+    },
+    responses: {
+      200: {
+        description: '更新成功',
+        content: { 'application/json': { schema: getSchema } }
+      },
+      400: {
+        description: '无效输入',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      404: {
+        description: '资源不存在',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 删除资源路由
+  const deleteRouteDef = createRoute({
+    method: 'delete',
+    path: '/{id}',
+    middleware,
+    request: {
+      params: z.object({
+        id: z.coerce.number().openapi({
+          param: { name: 'id', in: 'path' },
+          example: 1,
+          description: '资源ID'
+        })
+      })
+    },
+    responses: {
+      204: { description: '删除成功' },
+      404: {
+        description: '资源不存在',
+        content: { 'application/json': { schema: ErrorSchema } }
+      },
+      500: {
+        description: '服务器错误',
+        content: { 'application/json': { schema: ErrorSchema } }
+      }
+    }
+  });
+  
+  // 注册路由处理函数
+  const routes = app
+    .openapi(listRoute, async (c) => {
+      try {
+        const { page, pageSize, keyword, sortBy, sortOrder } = c.req.valid('query');
+        
+        // 构建排序对象
+        // 使用Record和类型断言解决泛型索引写入问题
+        const order: Partial<Record<keyof T, 'ASC' | 'DESC'>> = {};
+        if (sortBy) {
+          (order as Record<string, 'ASC' | 'DESC'>)[sortBy] = sortOrder || 'DESC';
+        } else {
+          // 默认按id降序排序
+          (order as Record<string, 'ASC' | 'DESC'>)['id'] = 'DESC';
+        }
+        
+        const [data, total] = await crudService.getList(
+          page,
+          pageSize,
+          keyword,
+          searchFields,
+          undefined, // where条件
+          [], // relations
+          order
+        );
+        
+        return c.json({
+          data: data as any[],
+          pagination: { total, current: page, pageSize }
+        }, 200);
+      } catch (error) {
+        if (error instanceof z.ZodError) {
+          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+        }
+        return c.json({
+          code: 500,
+          message: error instanceof Error ? error.message : '获取列表失败'
+        }, 500);
+      }
+    })
+    .openapi(createRouteDef, async (c) => {
+      try {
+        const data = c.req.valid('json');
+        const result = await crudService.create(data);
+        return c.json(result, 201);
+      } catch (error) {
+        if (error instanceof z.ZodError) {
+          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+        }
+        return c.json({
+          code: 500,
+          message: error instanceof Error ? error.message : '创建资源失败'
+        }, 500);
+      }
+    })
+    .openapi(getRouteDef, async (c) => {
+      try {
+        const { id } = c.req.valid('param');
+        const result = await crudService.getById(id);
+        
+        if (!result) {
+          return c.json({ code: 404, message: '资源不存在' }, 404);
+        }
+        
+        return c.json(result, 200);
+      } catch (error) {
+        if (error instanceof z.ZodError) {
+          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+        }
+        return c.json({
+          code: 500,
+          message: error instanceof Error ? error.message : '获取资源失败'
+        }, 500);
+      }
+    })
+    .openapi(updateRouteDef, async (c) => {
+      try {
+        const { id } = c.req.valid('param');
+        const data = c.req.valid('json');
+        const result = await crudService.update(id, data);
+        
+        if (!result) {
+          return c.json({ code: 404, message: '资源不存在' }, 404);
+        }
+        
+        return c.json(result, 200);
+      } catch (error) {
+        if (error instanceof z.ZodError) {
+          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+        }
+        if (error instanceof z.ZodError) {
+          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+        }
+        return c.json({
+          code: 500,
+          message: error instanceof Error ? error.message : '更新资源失败'
+        }, 500);
+      }
+    })
+    .openapi(deleteRouteDef, async (c) => {
+      try {
+        const { id } = c.req.valid('param');
+        const success = await crudService.delete(id);
+        
+        if (!success) {
+          return c.json({ code: 404, message: '资源不存在' }, 404);
+        }
+        
+        return c.body(null, 204) as unknown as Response;
+      } catch (error) {
+        if (error instanceof z.ZodError) {
+          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+        }
+        if (error instanceof z.ZodError) {
+          return c.json({ code: 400, message: '参数验证失败', errors: error.errors }, 400);
+        }
+        return c.json({
+          code: 500,
+          message: error instanceof Error ? error.message : '删除资源失败'
+        }, 500);
+      }
+    });
+  
+  return routes;
+}

+ 116 - 0
src/server/utils/generic-crud.service.ts

@@ -0,0 +1,116 @@
+import { DataSource, Repository, ObjectLiteral, DeepPartial } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+export abstract class GenericCrudService<T extends ObjectLiteral> {
+  protected repository: Repository<T>;
+  
+  constructor(
+    protected dataSource: DataSource,
+    protected entity: new () => T
+  ) {
+    this.repository = this.dataSource.getRepository(entity);
+  }
+
+  /**
+   * 获取分页列表
+   */
+  /**
+   * 获取分页列表,支持高级查询
+   */
+  async getList(
+    page: number = 1,
+    pageSize: number = 10,
+    keyword?: string,
+    searchFields?: string[],
+    where?: Partial<T>,
+    relations: string[] = [],
+    order: { [P in keyof T]?: 'ASC' | 'DESC' } = {}
+  ): Promise<[T[], number]> {
+    const skip = (page - 1) * pageSize;
+    const query = this.repository.createQueryBuilder('entity');
+    
+    // 关联查询
+    if (relations.length > 0) {
+      relations.forEach(relation => {
+        query.leftJoinAndSelect(`entity.${relation}`, relation);
+      });
+    }
+    
+    // 关键词搜索
+    if (keyword && searchFields && searchFields.length > 0) {
+      query.andWhere(searchFields.map(field => `entity.${field} LIKE :keyword`).join(' OR '), {
+        keyword: `%${keyword}%`
+      });
+    }
+    
+    // 条件查询
+    if (where) {
+      Object.entries(where).forEach(([key, value]) => {
+        if (value !== undefined && value !== null) {
+          query.andWhere(`entity.${key} = :${key}`, { [key]: value });
+        }
+      });
+    }
+    
+    // 排序
+    Object.entries(order).forEach(([key, direction]) => {
+      query.orderBy(`entity.${key}`, direction);
+    });
+    
+    return query.skip(skip).take(pageSize).getManyAndCount();
+  }
+
+  /**
+   * 高级查询方法
+   */
+  createQueryBuilder(alias: string = 'entity') {
+    return this.repository.createQueryBuilder(alias);
+  }
+
+  /**
+   * 根据ID获取单个实体
+   */
+  async getById(id: number): Promise<T | null> {
+    return this.repository.findOneBy({ id } as any);
+  }
+
+  /**
+   * 创建实体
+   */
+  async create(data: DeepPartial<T>): Promise<T> {
+    const entity = this.repository.create(data as DeepPartial<T>);
+    return this.repository.save(entity);
+  }
+
+  /**
+   * 更新实体
+   */
+  async update(id: number, data: Partial<T>): Promise<T | null> {
+    await this.repository.update(id, data);
+    return this.getById(id);
+  }
+
+  /**
+   * 删除实体
+   */
+  async delete(id: number): Promise<boolean> {
+    const result = await this.repository.delete(id);
+    return result.affected === 1;
+  }
+}
+
+export type CrudOptions<
+  T extends ObjectLiteral,
+  CreateSchema extends z.ZodSchema = z.ZodSchema,
+  UpdateSchema extends z.ZodSchema = z.ZodSchema,
+  GetSchema extends z.ZodSchema = z.ZodSchema,
+  ListSchema extends z.ZodSchema = z.ZodSchema
+> = {
+  entity: new () => T;
+  createSchema: CreateSchema;
+  updateSchema: UpdateSchema;
+  getSchema: GetSchema;
+  listSchema: ListSchema;
+  searchFields?: string[];
+  middleware?: any[];
+};