Explorar o código

✨ feat(user): 实现用户头像上传和管理功能

- 添加头像选择器组件,支持上传和选择头像文件
- 用户实体增加avatarFileId字段和与File实体的关联关系
- 更新用户schema,使用avatarFileId替代原avatar字段
- 用户服务查询时关联查询头像文件信息

♻️ refactor(api): 重构API文档生成和参数验证

- 修改OpenAPI文档生成方式,添加错误处理
- 优化Zod验证错误处理,使用JSON格式返回错误信息
- 新增parseWithAwait工具函数,支持异步解析包含Promise的数据

🔧 chore(schema): 修正文件和用户schema类型定义

- 修复FileSchema中fullUrl字段的Promise类型问题
- 调整日期字段使用coerce.date()确保正确解析
- 移除文件实体导入,统一使用schema定义
yourname hai 4 meses
pai
achega
4e43e84455

+ 19 - 0
src/client/admin-shadcn/pages/Users.tsx

@@ -595,6 +595,25 @@ export const UsersPage = () => {
         </DialogContent>
       </Dialog>
 
+      {/* 头像选择器 */}
+      <FileSelector
+        visible={isAvatarSelectorOpen}
+        onCancel={() => setIsAvatarSelectorOpen(false)}
+        onSelect={(file) => {
+          if (isCreateForm) {
+            createForm.setValue('avatarFileId', file.id);
+          } else {
+            updateForm.setValue('avatarFileId', file.id);
+          }
+          setIsAvatarSelectorOpen(false);
+        }}
+        accept="image/*"
+        maxSize={2}
+        uploadPath="/avatars"
+        uploadButtonText="上传头像"
+        multiple={false}
+      />
+
       {/* 删除确认对话框 */}
       <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
         <DialogContent>

+ 33 - 10
src/server/api.ts

@@ -40,16 +40,39 @@ api.openAPIRegistry.registerComponent('securitySchemes','bearerAuth',{
 // OpenAPI documentation endpoint
 // !import.meta.env.PROD
 if(1){
-  api.doc31('/doc', {
-    openapi: '3.1.0',
-    info: {
-      title: 'API Documentation',
-      version: '1.0.0'
-    },
-    security: [{
-      bearerAuth: []
-    }]
-    // servers: [{ url: '/api/v1' }]
+  // api.doc31('/doc', {
+  //   openapi: '3.1.0',
+  //   info: {
+  //     title: 'API Documentation',
+  //     version: '1.0.0'
+  //   },
+  //   security: [{
+  //     bearerAuth: []
+  //   }]
+  //   // servers: [{ url: '/api/v1' }]
+  // })
+  api.get('/doc', (c) => {
+    const config = {
+      openapi: '3.1.0',
+      info: {
+        title: 'API Documentation',
+        version: '1.0.0'
+      },
+      security: [{
+        bearerAuth: []
+      }]
+      // servers: [{ url: '/api/v1' }]
+    };
+    try {
+      const document = api.getOpenAPI31Document(config);
+      return c.json(document);
+    } catch (e: any) {  
+      return c.json({  
+        error: 'OpenAPI document generation failed',  
+        message: e.message || 'Unknown error',  
+        stack: e.stack  
+      }, 500)  
+    }
   })
 
   app.get('/ui', swaggerUI({

+ 1 - 1
src/server/api/files/upload-policy/post.ts

@@ -1,6 +1,6 @@
 import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
 import { FileService } from '@/server/modules/files/file.service';
-import { FileSchema, CreateFileDto, File } from '@/server/modules/files/file.entity';
+import { FileSchema, CreateFileDto } from '@/server/modules/files/file.schema';
 import { ErrorSchema } from '@/server/utils/errorHandler';
 import { AppDataSource } from '@/server/data-source';
 import { AuthContext } from '@/server/types/context';

+ 4 - 4
src/server/modules/files/file.schema.ts

@@ -22,7 +22,7 @@ export const FileSchema = z.object({
     description: '文件存储路径',
     example: '/uploads/documents/2023/project-plan.pdf'
   }),
-  fullUrl: z.promise(z.url()).openapi({
+  fullUrl: z.url().openapi({
     description: '完整文件访问URL',
     example: 'https://minio.example.com/d8dai/uploads/documents/2023/project-plan.pdf'
   }),
@@ -35,7 +35,7 @@ export const FileSchema = z.object({
     example: 1
   }),
   uploadUser: UserSchema,
-  uploadTime: z.date().openapi({
+  uploadTime: z.coerce.date().openapi({
     description: '上传时间',
     example: '2023-01-15T10:30:00Z'
   }),
@@ -43,11 +43,11 @@ export const FileSchema = z.object({
     description: '最后更新时间',
     example: '2023-01-16T14:20:00Z'
   }),
-  createdAt: z.date().openapi({
+  createdAt: z.coerce.date().openapi({
     description: '创建时间',
     example: '2023-01-15T10:30:00Z'
   }),
-  updatedAt: z.date().openapi({
+  updatedAt: z.coerce.date().openapi({
     description: '更新时间',
     example: '2023-01-16T14:20:00Z'
   })

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

@@ -1,5 +1,6 @@
-import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
 import { Role } from './role.entity';
+import { File } from '@/server/modules/files/file.entity';
 import { DeleteStatus, DisabledStatus } from '@/share/types';
 
 @Entity({ name: 'users' })
@@ -25,8 +26,12 @@ export class UserEntity {
   @Column({ name: 'name', type: 'varchar', length: 255, nullable: true, comment: '真实姓名' })
   name!: string | null;
 
-  @Column({ name: 'avatar', type: 'varchar', length: 255, nullable: true, comment: '头像' })
-  avatar!: string | null;
+  @Column({ name: 'avatar_file_id', type: 'int', unsigned: true, nullable: true, comment: '头像文件ID' })
+  avatarFileId!: number | null;
+
+  @ManyToOne(() => File, { nullable: true })
+  @JoinColumn({ name: 'avatar_file_id', referencedColumnName: 'id' })
+  avatarFile!: File | null;
 
   @Column({ name: 'is_disabled', type: 'int', default: DisabledStatus.ENABLED, comment: '是否禁用(0:启用,1:禁用)' })
   isDisabled!: DisabledStatus;

+ 21 - 11
src/server/modules/users/user.schema.ts

@@ -1,6 +1,7 @@
 import { z } from '@hono/zod-openapi';
 import { DeleteStatus, DisabledStatus } from '@/share/types';
 import { RoleSchema } from './role.schema';
+// import { FileSchema } from '@/server/modules/files/file.schema';
 
 // 基础用户 schema(包含所有字段)
 export const UserSchema = z.object({
@@ -29,9 +30,18 @@ export const UserSchema = z.object({
     example: '张三',
     description: '真实姓名'
   }),
-  avatar: z.string().max(255, '头像地址最多255个字符').nullable().openapi({
-    example: 'https://example.com/avatar.jpg',
-    description: '用户头像'
+  avatarFileId: z.number().int().positive().nullable().openapi({
+    example: 1,
+    description: '头像文件ID'
+  }),
+  avatarFile: z.object({
+    id: z.number().int().positive().openapi({ description: '文件ID' }),
+    name: z.string().max(255).openapi({ description: '文件名', example: 'avatar.jpg' }),
+    fullUrl: z.string().openapi({ description: '文件完整URL', example: 'https://example.com/avatar.jpg' }),
+    type: z.string().nullable().openapi({ description: '文件类型', example: 'image/jpeg' }),
+    size: z.number().nullable().openapi({ description: '文件大小(字节)', example: 102400 })
+  }).optional().openapi({
+    description: '头像文件信息'
   }),
   isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED).openapi({
     example: DisabledStatus.ENABLED,
@@ -54,8 +64,8 @@ export const UserSchema = z.object({
     ],
     description: '用户角色列表'
   }),
-  createdAt: z.date().openapi({ description: '创建时间' }),
-  updatedAt: z.date().openapi({ description: '更新时间' })
+  createdAt: z.coerce.date().openapi({ description: '创建时间' }),
+  updatedAt: z.coerce.date().openapi({ description: '更新时间' })
 });
 
 // 创建用户请求 schema
@@ -84,9 +94,9 @@ export const CreateUserDto = z.object({
     example: '张三',
     description: '真实姓名'
   }),
-  avatar: z.string().max(255, '头像地址最多255个字符').nullable().optional().openapi({
-    example: 'https://example.com/avatar.jpg',
-    description: '用户头像'
+  avatarFileId: z.number().int().positive().nullable().optional().openapi({
+    example: 1,
+    description: '头像文件ID'
   }),
   isDisabled: z.number().int().min(0, '状态值只能是0或1').max(1, '状态值只能是0或1').default(DisabledStatus.ENABLED).optional().openapi({
     example: DisabledStatus.ENABLED,
@@ -120,9 +130,9 @@ export const UpdateUserDto = z.object({
     example: '张三',
     description: '真实姓名'
   }),
-  avatar: z.string().max(255, '头像地址最多255个字符').nullable().optional().openapi({
-    example: 'https://example.com/avatar.jpg',
-    description: '用户头像'
+  avatarFileId: z.number().int().positive().nullable().optional().openapi({
+    example: 1,
+    description: '头像文件ID'
   }),
   isDisabled: z.number().int().min(0, '状态值只能是0或1').max(1, '状态值只能是0或1').optional().openapi({
     example: DisabledStatus.ENABLED,

+ 2 - 2
src/server/modules/users/user.service.ts

@@ -34,7 +34,7 @@ export class UserService {
     try {
       return await this.userRepository.findOne({ 
         where: { id },
-        relations: ['roles']
+        relations: ['roles', 'avatarFile']
       });
     } catch (error) {
       console.error('Error getting user:', error);
@@ -156,7 +156,7 @@ export class UserService {
     try {
       return await this.userRepository.findOne({
         where: [{ username: account }, { email: account }],
-        relations: ['roles']
+        relations: ['roles', 'avatarFile']
       });
     } catch (error) {
       console.error('Error getting user by account:', error);

+ 10 - 7
src/server/utils/generic-crud.routes.ts

@@ -5,6 +5,7 @@ import { ErrorSchema } from './errorHandler';
 import { AuthContext } from '../types/context';
 import { ObjectLiteral } from 'typeorm';
 import { AppDataSource } from '../data-source';
+import { parseWithAwait } from './parseWithAwait';
 
 export function createCrudRoutes<
   T extends ObjectLiteral,
@@ -253,12 +254,13 @@ export function createCrudRoutes<
         );
         
         return c.json({
-          data: await z.array(listSchema).parseAsync(data),
+          // data: z.array(listSchema).parse(data),
+          data: await parseWithAwait(z.array(listSchema), data),
           pagination: { total, current: page, pageSize }
         }, 200);
       } catch (error) {
         if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.message }, 400);
+          return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
         }
         return c.json({
           code: 500,
@@ -274,7 +276,7 @@ export function createCrudRoutes<
         return c.json(result, 201);
       } catch (error) {
         if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.message }, 400);
+          return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
         }
         return c.json({
           code: 500,
@@ -291,10 +293,11 @@ export function createCrudRoutes<
           return c.json({ code: 404, message: '资源不存在' }, 404);
         }
         
-        return c.json(await getSchema.parseAsync(result), 200);
+        // return c.json(await getSchema.parseAsync(result), 200);
+        return c.json(await parseWithAwait(getSchema, data), 200);
       } catch (error) {
         if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.message }, 400);
+          return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
         }
         return c.json({
           code: 500,
@@ -316,7 +319,7 @@ export function createCrudRoutes<
         return c.json(result, 200);
       } catch (error) {
         if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.message }, 400);
+          return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
         }
         return c.json({
           code: 500,
@@ -336,7 +339,7 @@ export function createCrudRoutes<
         return c.body(null, 204);
       } catch (error) {
         if (error instanceof z.ZodError) {
-          return c.json({ code: 400, message: '参数验证失败', errors: error.message }, 400);
+          return c.json({ code: 400, message: '参数验证失败', errors: JSON.parse(error.message) }, 400);
         }
         return c.json({
           code: 500,

+ 59 - 0
src/server/utils/parseWithAwait.ts

@@ -0,0 +1,59 @@
+import { z } from '@hono/zod-openapi';
+
+export async function parseWithAwait<T>(schema: z.ZodSchema<T>, data: unknown): Promise<T> {  
+    // 先尝试同步解析,捕获 Promise 错误  
+    const syncResult = schema.safeParse(data);  
+      
+    if (!syncResult.success) {  
+      // 提取 Promise 错误的路径信息  
+      const promiseErrors = syncResult.error.issues.filter(issue =>   
+        issue.code === 'invalid_type' &&   
+        issue.message.includes('received Promise')  
+      );  
+        
+      if (promiseErrors.length > 0) {  
+        // 根据路径直接 await Promise  
+        const processedData = await resolvePromisesByPath(data, promiseErrors);  
+          
+        // 重新解析处理后的数据  
+        return schema.parse(processedData) as T;  
+      }  
+        
+      throw syncResult.error;  
+    }  
+      
+    return syncResult.data as T;  
+  }  
+    
+  async function resolvePromisesByPath(data: any, promiseErrors: any[]): Promise<any> {  
+    const clonedData = JSON.parse(JSON.stringify(data, (key, value) => {  
+      // 保留 Promise 对象,不进行序列化  
+      return typeof value?.then === 'function' ? value : value;  
+    }));  
+      
+    // 根据错误路径逐个处理 Promise  
+    for (const error of promiseErrors) {  
+      const path = error.path;  
+      const promiseValue = getValueByPath(data, path);  
+        
+      if (promiseValue && typeof promiseValue.then === 'function') {  
+        const resolvedValue = await promiseValue;  
+        setValueByPath(clonedData, path, resolvedValue);  
+      }  
+    }  
+      
+    return clonedData;  
+  }  
+    
+  function getValueByPath(obj: any, path: (string | number)[]): any {  
+    return path.reduce((current, key) => current?.[key], obj);  
+  }  
+    
+  function setValueByPath(obj: any, path: (string | number)[], value: any): void {  
+    const lastKey = path[path.length - 1];  
+    const parentPath = path.slice(0, -1);  
+    const parent = getValueByPath(obj, parentPath);  
+    if (parent) {  
+      parent[lastKey] = value;  
+    }  
+  }