Ver código fonte

✨ feat(admin): 重构管理后台UI并新增文件管理功能

- 将Ant Design组件替换为Tailwind CSS和Radix UI组件,提升现代化程度
- 新增移动端响应式布局和抽屉式菜单支持
- 集成sonner通知组件用于用户交互反馈
- 新增File实体和MinIO文件存储服务,支持文件上传下载
- 重构用户头像存储逻辑,从URL存储改为文件关联
- 新增用户和角色的schema定义,优化API文档结构
yourname 7 meses atrás
pai
commit
effbd70f91

+ 2 - 0
src/client/admin/index.tsx

@@ -10,6 +10,7 @@ import zhCN from 'antd/locale/zh_CN';
 
 
 import { AuthProvider } from './hooks/AuthProvider';
 import { AuthProvider } from './hooks/AuthProvider';
 import { router } from './routes';
 import { router } from './routes';
+import { Toaster } from '../components/ui/sonner';
 
 
 // 配置 dayjs 插件
 // 配置 dayjs 插件
 dayjs.extend(weekday);
 dayjs.extend(weekday);
@@ -47,6 +48,7 @@ const App = () => {
           </AuthProvider>
           </AuthProvider>
         </AntdApp>
         </AntdApp>
       </ConfigProvider>
       </ConfigProvider>
+      <Toaster />
     </QueryClientProvider>
     </QueryClientProvider>
   )
   )
 };
 };

+ 185 - 157
src/client/admin/layouts/MainLayout.tsx

@@ -1,24 +1,25 @@
-import React, { useState, useEffect, useMemo } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import {
 import {
   Outlet,
   Outlet,
   useLocation,
   useLocation,
 } from 'react-router';
 } from 'react-router';
 import {
 import {
-  Layout, Button, Space, Badge, Avatar, Dropdown, Typography, Input, Menu,
-} from 'antd';
-import {
-  MenuFoldOutlined,
-  MenuUnfoldOutlined,
-  BellOutlined,
-  VerticalAlignTopOutlined,
-  UserOutlined
-} from '@ant-design/icons';
+  Bell,
+  Menu,
+  User,
+  ChevronDown
+} from 'lucide-react';
 import { useAuth } from '../hooks/AuthProvider';
 import { useAuth } from '../hooks/AuthProvider';
-import { useMenu, useMenuSearch, type MenuItem } from '../menu';
+import { useMenu, type MenuItem } from '../menu';
 import { getGlobalConfig } from '@/client/utils/utils';
 import { getGlobalConfig } from '@/client/utils/utils';
-
-const { Header, Sider, Content } = Layout;
-
+import { Button } from '@/client/components/ui/button';
+import { Input } from '@/client/components/ui/input';
+import { Avatar, AvatarFallback, AvatarImage } from '@/client/components/ui/avatar';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/client/components/ui/dropdown-menu';
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/client/components/ui/sheet';
+import { ScrollArea } from '@/client/components/ui/scroll-area';
+import { cn } from '@/client/lib/utils';
+import { Badge } from '@/client/components/ui/badge';
 /**
 /**
  * 主布局组件
  * 主布局组件
  * 包含侧边栏、顶部导航和内容区域
  * 包含侧边栏、顶部导航和内容区域
@@ -27,46 +28,17 @@ export const MainLayout = () => {
   const { user } = useAuth();
   const { user } = useAuth();
   const [showBackTop, setShowBackTop] = useState(false);
   const [showBackTop, setShowBackTop] = useState(false);
   const location = useLocation();
   const location = useLocation();
+  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
   
   
   // 使用菜单hook
   // 使用菜单hook
   const {
   const {
     menuItems,
     menuItems,
     userMenuItems,
     userMenuItems,
-    openKeys,
     collapsed,
     collapsed,
     setCollapsed,
     setCollapsed,
-    handleMenuClick: handleRawMenuClick,
-    onOpenChange
+    handleMenuClick
   } = useMenu();
   } = useMenu();
   
   
-  // 处理菜单点击
-  const handleMenuClick = (key: string) => {
-    const item = findMenuItem(menuItems, key);
-    if (item && 'label' in item) {
-      handleRawMenuClick(item);
-    }
-  };
-  
-  // 查找菜单项
-  const findMenuItem = (items: MenuItem[], key: string): MenuItem | null => {
-    for (const item of items) {
-      if (!item) continue;
-      if (item.key === key) return item;
-      if (item.children) {
-        const found = findMenuItem(item.children, key);
-        if (found) return found;
-      }
-    }
-    return null;
-  };
-  
-  // 使用菜单搜索hook
-  const {
-    searchText,
-    setSearchText,
-    filteredMenuItems
-  } = useMenuSearch(menuItems);
-  
   // 获取当前选中的菜单项
   // 获取当前选中的菜单项
   const selectedKey = useMemo(() => {
   const selectedKey = useMemo(() => {
     const findSelectedKey = (items: MenuItem[]): string | null => {
     const findSelectedKey = (items: MenuItem[]): string | null => {
@@ -102,127 +74,183 @@ export const MainLayout = () => {
     });
     });
   };
   };
 
 
-  
   // 应用名称 - 从CONFIG中获取或使用默认值
   // 应用名称 - 从CONFIG中获取或使用默认值
   const appName = getGlobalConfig('APP_NAME') || '应用Starter';
   const appName = getGlobalConfig('APP_NAME') || '应用Starter';
   
   
-  return (
-    <Layout style={{ minHeight: '100vh' }}>
-      <Sider
-        trigger={null}
-        collapsible
-        collapsed={collapsed}
-        width={240}
-        className="custom-sider"
-        theme='light'
-        style={{
-          overflow: 'auto',
-          height: '100vh',
-          position: 'fixed',
-          left: 0,
-          top: 0,
-          bottom: 0,
-          zIndex: 100,
-          transition: 'all 0.2s ease',
-          boxShadow: '2px 0 8px 0 rgba(29, 35, 41, 0.05)',
-          background: 'linear-gradient(180deg, #001529 0%, #003a6c 100%)',
-        }}
-      >
-        <div className="p-4">
-          <Typography.Title level={2} className="text-xl font-bold truncate">
-            <span className="text-white">{collapsed ? '应用' : appName}</span>
-          </Typography.Title>
-          
-          {/* 菜单搜索框 */}
-          {!collapsed && (
-            <div className="mb-4">
-              <Input.Search
-                placeholder="搜索菜单..."
-                allowClear
-                value={searchText}
-                onChange={(e) => setSearchText(e.target.value)}
-              />
-            </div>
-          )}
-        </div>
-        
-        {/* 菜单列表 */}
-        <Menu
-          theme='dark'
-          mode="inline"
-          items={filteredMenuItems}
-          openKeys={openKeys}
-          selectedKeys={[selectedKey]}
-          onOpenChange={onOpenChange}
-          onClick={({ key }) => handleMenuClick(key)}
-          inlineCollapsed={collapsed}
-          style={{
-            backgroundColor: 'transparent',
-            borderRight: 'none'
-          }}
-        />
-      </Sider>
+
+  // 侧边栏内容
+  const SidebarContent = () => (
+    <div className="flex h-full flex-col">
+      <div className="p-4 border-b">
+        <h2 className="text-lg font-semibold truncate">
+          {collapsed ? '应用' : appName}
+        </h2>
+        {!collapsed && (
+          <div className="mt-4">
+            <Input
+              placeholder="搜索菜单..."
+              className="h-8"
+            />
+          </div>
+        )}
+      </div>
       
       
-      <Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
-        <div className="sticky top-0 z-50 bg-white shadow-sm transition-all duration-200 h-16 flex items-center justify-between pl-2"
-          style={{
-            boxShadow: '0 1px 8px rgba(0,21,41,0.12)',
-            borderBottom: '1px solid #f0f0f0'
-          }}
-        >
-          <Button
-            type="text"
-            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
-            onClick={() => setCollapsed(!collapsed)}
-            className="w-16 h-16"
-          />
-          
-          <Space size="middle" className="mr-4">
-            <Badge count={5} offset={[0, 5]}>
-              <Button 
-                type="text" 
-                icon={<BellOutlined />}
-              />
-            </Badge>
-            
-            <Dropdown menu={{ items: userMenuItems }}>
-              <Space className="cursor-pointer">
-                <Avatar 
-                  src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
-                  icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
-                />
-                <span>
-                  {user?.nickname || user?.username}
-                </span>
-              </Space>
-            </Dropdown>
-          </Space>
-        </div>
-        
-        <Content className="m-6" style={{ overflow: 'initial', transition: 'all 0.2s ease' }}>
-          <div className="site-layout-content p-6 rounded-lg bg-white shadow-sm transition-all duration-300 hover:shadow-md">
+      <ScrollArea className="flex-1">
+        <nav className="p-2">
+          {menuItems.map((item) => (
+            <div key={item.key}>
+              <Button
+                variant={selectedKey === item.key ? "default" : "ghost"}
+                className={cn(
+                  "w-full justify-start mb-1",
+                  selectedKey === item.key && "bg-primary text-primary-foreground"
+                )}
+                onClick={() => {
+                  handleMenuClick(item);
+                  setIsMobileMenuOpen(false);
+                }}
+              >
+                {item.icon}
+                {!collapsed && <span className="ml-2">{item.label}</span>}
+              </Button>
+              
+              {item.children && !collapsed && (
+                <div className="ml-4">
+                  {item.children.map((child) => (
+                    <Button
+                      key={child.key}
+                      variant={selectedKey === child.key ? "default" : "ghost"}
+                      className={cn(
+                        "w-full justify-start mb-1 text-sm",
+                        selectedKey === child.key && "bg-primary text-primary-foreground"
+                      )}
+                      onClick={() => {
+                        handleMenuClick(child);
+                        setIsMobileMenuOpen(false);
+                      }}
+                    >
+                      {child.icon && <span className="ml-2">{child.icon}</span>}
+                      <span className={child.icon ? "ml-2" : "ml-6"}>{child.label}</span>
+                    </Button>
+                  ))}
+                </div>
+              )}
+            </div>
+          ))}
+        </nav>
+      </ScrollArea>
+    </div>
+  );
+
+  return (
+    <div className="flex h-screen bg-background">
+      {/* Desktop Sidebar */}
+      <aside className={cn(
+        "hidden md:block border-r bg-background transition-all duration-200",
+        collapsed ? "w-16" : "w-64"
+      )}>
+        <SidebarContent />
+      </aside>
+
+      {/* Mobile Sidebar */}
+      <Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
+        <SheetContent side="left" className="w-64 p-0">
+          <SheetHeader className="p-4">
+            <SheetTitle>{appName}</SheetTitle>
+          </SheetHeader>
+          <SidebarContent />
+        </SheetContent>
+      </Sheet>
+
+      <div className="flex-1 flex flex-col overflow-hidden">
+        {/* Header */}
+        <header className="flex h-16 items-center justify-between border-b bg-background px-4">
+          <div className="flex items-center gap-2">
+            <Button
+              variant="ghost"
+              size="icon"
+              className="md:hidden"
+              onClick={() => setIsMobileMenuOpen(true)}
+            >
+              <Menu className="h-4 w-4" />
+            </Button>
+            <Button
+              variant="ghost"
+              size="icon"
+              className="hidden md:block"
+              onClick={() => setCollapsed(!collapsed)}
+            >
+              <Menu className="h-4 w-4" />
+            </Button>
+          </div>
+
+          <div className="flex items-center gap-4">
+            <Button variant="ghost" size="icon" className="relative">
+              <Bell className="h-4 w-4" />
+              <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs">
+                5
+              </Badge>
+            </Button>
+
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button variant="ghost" className="relative h-8 w-8 rounded-full">
+                  <Avatar className="h-8 w-8">
+                    <AvatarImage
+                      src={user?.avatarFile?.fullUrl || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
+                      alt={user?.username || 'User'}
+                    />
+                    <AvatarFallback>
+                      <User className="h-4 w-4" />
+                    </AvatarFallback>
+                  </Avatar>
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent className="w-56" align="end" forceMount>
+                <DropdownMenuLabel className="font-normal">
+                  <div className="flex flex-col space-y-1">
+                    <p className="text-sm font-medium leading-none">
+                      {user?.nickname || user?.username}
+                    </p>
+                    <p className="text-xs leading-none text-muted-foreground">
+                      {user?.email}
+                    </p>
+                  </div>
+                </DropdownMenuLabel>
+                <DropdownMenuSeparator />
+                {userMenuItems.map((item) => (
+                  item.type === 'separator' ? (
+                    <DropdownMenuSeparator key={item.key} />
+                  ) : (
+                    <DropdownMenuItem key={item.key} onClick={item.onClick}>
+                      {item.icon && item.icon}
+                      <span>{item.label}</span>
+                    </DropdownMenuItem>
+                  )
+                ))}
+              </DropdownMenuContent>
+            </DropdownMenu>
+          </div>
+        </header>
+
+        {/* Main Content */}
+        <main className="flex-1 overflow-auto p-4">
+          <div className="max-w-7xl mx-auto">
             <Outlet />
             <Outlet />
           </div>
           </div>
           
           
-          {/* 回到顶部按钮 */}
+          {/* Back to top button */}
           {showBackTop && (
           {showBackTop && (
             <Button
             <Button
-              type="primary"
-              shape="circle"
-              icon={<VerticalAlignTopOutlined />}
-              size="large"
+              size="icon"
+              className="fixed bottom-4 right-4 rounded-full shadow-lg"
               onClick={scrollToTop}
               onClick={scrollToTop}
-              style={{
-                position: 'fixed',
-                right: 30,
-                bottom: 30,
-                zIndex: 1000,
-                boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
-              }}
-            />
+            >
+              <ChevronDown className="h-4 w-4 rotate-180" />
+            </Button>
           )}
           )}
-        </Content>
-      </Layout>
-    </Layout>
+        </main>
+      </div>
+    </div>
   );
   );
-};
+};

+ 1 - 1
src/server/api/auth/login/password.ts

@@ -5,7 +5,7 @@ import { z } from '@hono/zod-openapi'
 import { ErrorSchema } from '../../../utils/errorHandler'
 import { ErrorSchema } from '../../../utils/errorHandler'
 import { AppDataSource } from '../../../data-source'
 import { AppDataSource } from '../../../data-source'
 import { AuthContext } from '../../../types/context'
 import { AuthContext } from '../../../types/context'
-import { UserSchema } from '@/server/modules/users/user.entity'
+import { UserSchema } from '@/server/modules/users/user.schema'
 
 
 const userService = new UserService(AppDataSource)
 const userService = new UserService(AppDataSource)
 const authService = new AuthService(userService)
 const authService = new AuthService(userService)

+ 1 - 1
src/server/api/auth/me/get.ts

@@ -2,7 +2,7 @@ import { createRoute, OpenAPIHono } from '@hono/zod-openapi'
 import { ErrorSchema } from '@/server/utils/errorHandler'
 import { ErrorSchema } from '@/server/utils/errorHandler'
 import { authMiddleware } from '@/server/middleware/auth.middleware'
 import { authMiddleware } from '@/server/middleware/auth.middleware'
 import { AuthContext } from '@/server/types/context'
 import { AuthContext } from '@/server/types/context'
-import { UserSchema } from '../../../modules/users/user.entity'
+import { UserSchema } from '@/server/modules/users/user.schema'
 
 
 const UserResponseSchema = UserSchema.omit({
 const UserResponseSchema = UserSchema.omit({
   password: true
   password: true

+ 2 - 1
src/server/data-source.ts

@@ -10,6 +10,7 @@ import { DateNotes } from "./modules/stock/date-notes.entity"
 import { StockData } from "./modules/stock/stock-data.entity"
 import { StockData } from "./modules/stock/stock-data.entity"
 import { StockXunlianCodes } from "./modules/stock/stock-xunlian-codes.entity"
 import { StockXunlianCodes } from "./modules/stock/stock-xunlian-codes.entity"
 import { SubmissionRecords } from "./modules/submission/submission-records.entity"
 import { SubmissionRecords } from "./modules/submission/submission-records.entity"
+import { File } from "./modules/files/file.entity"
 
 
 export const AppDataSource = new DataSource({
 export const AppDataSource = new DataSource({
   type: "mysql",
   type: "mysql",
@@ -19,7 +20,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
   entities: [
-    User, Role, ClassroomData, DateNotes, StockData, StockXunlianCodes, SubmissionRecords, 
+    User, Role, File, ClassroomData, DateNotes, StockData, StockXunlianCodes, SubmissionRecords, 
   ],
   ],
   migrations: [],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 80 - 0
src/server/modules/files/file.entity.ts

@@ -0,0 +1,80 @@
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
+import { UserEntity } from '@/server/modules/users/user.entity';
+import process from 'node:process';
+import { MinioService } from './minio.service';
+
+@Entity('file')
+export class File {
+  @PrimaryGeneratedColumn({ name: 'id', type: 'int', unsigned: true })
+  id!: number;
+
+  @Column({ name: 'name', type: 'varchar', length: 255 })
+  name!: string;
+
+  @Column({ name: 'type', type: 'varchar', length: 50, nullable: true, comment: '文件类型' })
+  type!: string | null;
+
+  @Column({ name: 'size', type: 'int', unsigned: true, nullable: true, comment: '文件大小,单位字节' })
+  size!: number | null;
+
+  @Column({ name: 'path', type: 'varchar', length: 512, comment: '文件存储路径' })
+  path!: string;
+
+  // 获取完整的文件URL(包含MINIO_HOST前缀)
+  // get fullUrl(): string {
+  //   const protocol = process.env.MINIO_USE_SSL !== 'false' ? 'https' : 'http';
+  //   const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
+  //   const host = process.env.MINIO_HOST || 'localhost';
+  //   const bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
+  //   return `${protocol}://${host}${port}/${bucketName}/${this.path}`;
+  // }
+  get fullUrl(): Promise<string> {
+    // 创建MinioService实例
+    const minioService = new MinioService();
+    // 获取配置的桶名称
+    const bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
+    
+    // 返回一个Promise,内部处理异步获取URL的逻辑
+    return new Promise((resolve, reject) => {
+      // 调用minioService的异步方法
+      minioService.getPresignedFileUrl(bucketName, this.path)
+        .then(url => {
+          // 成功获取URL后解析Promise
+          resolve(url);
+        })
+        .catch(error => {
+          // 处理可能的错误
+          console.error('获取文件预签名URL失败:', error);
+          reject(error); // 将错误传递出去
+        });
+    });
+  }
+  
+
+  @Column({ name: 'description', type: 'text', nullable: true, comment: '文件描述' })
+  description!: string | null;
+
+  @Column({ name: 'upload_user_id', type: 'int', unsigned: true })
+  uploadUserId!: number;
+
+  @ManyToOne(() => UserEntity)
+  @JoinColumn({ name: 'upload_user_id', referencedColumnName: 'id' })
+  uploadUser!: UserEntity;
+
+  @Column({ name: 'upload_time', type: 'datetime' })
+  uploadTime!: Date;
+
+  @Column({ name: 'last_updated', type: 'datetime', nullable: true, comment: '最后更新时间' })
+  lastUpdated!: Date | null;
+
+  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
+  createdAt!: Date;
+
+  @Column({ 
+    name: 'updated_at', 
+    type: 'timestamp', 
+    default: () => 'CURRENT_TIMESTAMP', 
+    onUpdate: 'CURRENT_TIMESTAMP' 
+  })
+  updatedAt!: Date;
+}

+ 92 - 0
src/server/modules/files/file.schema.ts

@@ -0,0 +1,92 @@
+import { z } from '@hono/zod-openapi';
+import { UserSchema } from '../users/user.schema';
+
+export const FileSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '文件ID',
+    example: 1
+  }),
+  name: z.string().max(255).openapi({
+    description: '文件名称',
+    example: '项目计划书.pdf'
+  }),
+  type: z.string().max(50).nullable().openapi({
+    description: '文件类型',
+    example: 'application/pdf'
+  }),
+  size: z.number().int().positive().nullable().openapi({
+    description: '文件大小,单位字节',
+    example: 102400
+  }),
+  path: z.string().max(512).openapi({
+    description: '文件存储路径',
+    example: '/uploads/documents/2023/project-plan.pdf'
+  }),
+  fullUrl: z.url().openapi({
+    description: '完整文件访问URL',
+    example: 'https://minio.example.com/d8dai/uploads/documents/2023/project-plan.pdf'
+  }),
+  description: z.string().nullable().openapi({
+    description: '文件描述',
+    example: '2023年度项目计划书'
+  }),
+  uploadUserId: z.number().int().positive().openapi({
+    description: '上传用户ID',
+    example: 1
+  }),
+  uploadUser: UserSchema,
+  uploadTime: z.coerce.date().openapi({
+    description: '上传时间',
+    example: '2023-01-15T10:30:00Z'
+  }),
+  lastUpdated: z.date().nullable().openapi({
+    description: '最后更新时间',
+    example: '2023-01-16T14:20:00Z'
+  }),
+  createdAt: z.coerce.date().openapi({
+    description: '创建时间',
+    example: '2023-01-15T10:30:00Z'
+  }),
+  updatedAt: z.coerce.date().openapi({
+    description: '更新时间',
+    example: '2023-01-16T14:20:00Z'
+  })
+});
+
+export const CreateFileDto = z.object({
+  name: z.string().max(255).openapi({
+    description: '文件名称',
+    example: '项目计划书.pdf'
+  }),
+  type: z.string().max(50).nullable().optional().openapi({
+    description: '文件类型',
+    example: 'application/pdf'
+  }),
+  size: z.coerce.number().int().positive().nullable().optional().openapi({
+    description: '文件大小,单位字节',
+    example: 102400
+  }),
+  path: z.string().max(512).openapi({
+    description: '文件存储路径',
+    example: '/uploads/documents/2023/project-plan.pdf'
+  }),
+  description: z.string().nullable().optional().openapi({
+    description: '文件描述',
+    example: '2023年度项目计划书'
+  }),
+  lastUpdated: z.coerce.date().nullable().optional().openapi({
+    description: '最后更新时间',
+    example: '2023-01-16T14:20:00Z'
+  })
+});
+
+export const UpdateFileDto = z.object({
+  name: z.string().max(255).optional().openapi({
+    description: '文件名称',
+    example: '项目计划书_v2.pdf'
+  }),
+  description: z.string().nullable().optional().openapi({
+    description: '文件描述',
+    example: '2023年度项目计划书(修订版)'
+  })
+});

+ 219 - 0
src/server/modules/files/file.service.ts

@@ -0,0 +1,219 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { File } from './file.entity';
+import { MinioService } from './minio.service';
+// import { AppError } from '@/server/utils/errorHandler';
+import { v4 as uuidv4 } from 'uuid';
+import { logger } from '@/server/utils/logger';
+
+export class FileService extends GenericCrudService<File> {
+  private readonly minioService: MinioService;
+
+  constructor(dataSource: DataSource) {
+    super(dataSource, File);
+    this.minioService = new MinioService();
+  }
+
+  /**
+   * 创建文件记录并生成预签名上传URL
+   */
+  async createFile(data: Partial<File>) {
+    try {
+      // 生成唯一文件存储路径
+      const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`;
+      
+      // 生成MinIO上传策略
+      const uploadPolicy = await this.minioService.generateUploadPolicy(fileKey);
+      
+      // 准备文件记录数据
+      const fileData = {
+        ...data,
+        path: fileKey,
+        uploadTime: new Date(),
+        createdAt: new Date(),
+        updatedAt: new Date()
+      };
+      
+      // 保存文件记录到数据库
+      const savedFile = await this.create(fileData as File);
+      
+      // 返回文件记录和上传策略
+      return {
+        file: savedFile,
+        uploadPolicy
+      };
+    } catch (error) {
+      logger.error('Failed to create file:', error);
+      throw new Error('文件创建失败');
+    }
+  }
+
+  /**
+   * 删除文件记录及对应的MinIO文件
+   */
+  async deleteFile(id: number) {
+    try {
+      // 获取文件记录
+      const file = await this.getById(id);
+      if (!file) {
+        throw new Error('文件不存在');
+      }
+      
+      // 验证文件是否存在于MinIO
+      const fileExists = await this.minioService.objectExists(this.minioService.bucketName, file.path);
+      if (!fileExists) {
+        logger.error(`File not found in MinIO: ${this.minioService.bucketName}/${file.path}`);
+        // 仍然继续删除数据库记录,但记录警告日志
+      } else {
+        // 从MinIO删除文件
+        await this.minioService.deleteObject(this.minioService.bucketName, file.path);
+      }
+      
+      // 从数据库删除记录
+      await this.delete(id);
+      
+      return true;
+    } catch (error) {
+      logger.error('Failed to delete file:', error);
+      throw new Error('文件删除失败');
+    }
+  }
+
+  /**
+   * 获取文件访问URL
+   */
+  async getFileUrl(id: number) {
+    const file = await this.getById(id);
+    if (!file) {
+      throw new Error('文件不存在');
+    }
+    
+    return this.minioService.getPresignedFileUrl(this.minioService.bucketName, file.path);
+  }
+
+  /**
+   * 获取文件下载URL(带Content-Disposition头)
+   */
+  async getFileDownloadUrl(id: number) {
+    const file = await this.getById(id);
+    if (!file) {
+      throw new Error('文件不存在');
+    }
+    
+    const url = await this.minioService.getPresignedFileDownloadUrl(
+      this.minioService.bucketName,
+      file.path,
+      file.name
+    );
+    
+    return {
+      url,
+      filename: file.name
+    };
+  }
+
+  /**
+   * 创建多部分上传策略
+   */
+  async createMultipartUploadPolicy(data: Partial<File>, partCount: number) {
+    try {
+      // 生成唯一文件存储路径
+      const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`;
+      
+      // 初始化多部分上传
+      const uploadId = await this.minioService.createMultipartUpload(
+        this.minioService.bucketName,
+        fileKey
+      );
+      
+      // 生成各部分上传URL
+      const uploadUrls = await this.minioService.generateMultipartUploadUrls(
+        this.minioService.bucketName,
+        fileKey,
+        uploadId,
+        partCount
+      );
+      
+      // 准备文件记录数据
+      const fileData = {
+        ...data,
+        path: fileKey,
+        uploadTime: new Date(),
+        createdAt: new Date(),
+        updatedAt: new Date()
+      };
+      
+      // 保存文件记录到数据库
+      const savedFile = await this.create(fileData as File);
+      
+      // 返回文件记录和上传策略
+      return {
+        file: savedFile,
+        uploadId,
+        uploadUrls,
+        bucket: this.minioService.bucketName,
+        key: fileKey
+      };
+    } catch (error) {
+      logger.error('Failed to create multipart upload policy:', error);
+      throw new Error('创建多部分上传策略失败');
+    }
+  }
+
+  /**
+   * 完成分片上传
+   */
+  async completeMultipartUpload(data: {
+    uploadId: string;
+    bucket: string;
+    key: string;
+    parts: Array<{ partNumber: number; etag: string }>;
+  }) {
+    try {
+      logger.db('Starting multipart upload completion:', {
+        uploadId: data.uploadId,
+        bucket: data.bucket,
+        key: data.key,
+        partsCount: data.parts.length
+      });
+
+      // 完成MinIO分片上传 - 注意格式转换
+      const result = await this.minioService.completeMultipartUpload(
+        data.bucket,
+        data.key,
+        data.uploadId,
+        data.parts.map(part => ({ PartNumber: part.partNumber, ETag: part.etag }))
+      );
+      
+      // 查找文件记录并更新
+      const file = await this.repository.findOneBy({ path: data.key });
+      if (!file) {
+        throw new Error('文件记录不存在');
+      }
+      
+      // 更新文件大小等信息
+      file.size = result.size;
+      file.updatedAt = new Date();
+      await this.repository.save(file);
+      
+      // 生成文件访问URL
+      const url = this.minioService.getFileUrl(data.bucket, data.key);
+      
+      logger.db('Multipart upload completed successfully:', {
+        fileId: file.id,
+        size: result.size,
+        key: data.key
+      });
+      
+      return {
+        fileId: file.id,
+        url,
+        key: data.key,
+        size: result.size
+      };
+    } catch (error) {
+      logger.error('Failed to complete multipart upload:', error);
+      throw new Error('完成分片上传失败');
+    }
+  }
+}

+ 236 - 0
src/server/modules/files/minio.service.ts

@@ -0,0 +1,236 @@
+import { Client } from 'minio';
+import { logger } from '@/server/utils/logger';
+import * as process from 'node:process';
+
+export class MinioService {
+  private readonly client: Client;
+  public readonly bucketName: string;
+
+  constructor() {
+    this.client = new Client({
+      endPoint: process.env.MINIO_HOST || 'localhost',
+      port: parseInt(process.env.MINIO_PORT || '443'),
+      useSSL: process.env.MINIO_USE_SSL !== 'false',
+      accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
+      secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
+    });
+    this.bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
+  }
+
+  // 设置桶策略为"公读私写"
+  async setPublicReadPolicy(bucketName: string = this.bucketName) {
+    const policy = {
+      "Version": "2012-10-17",
+      "Statement": [
+        {
+          "Effect": "Allow",
+          "Principal": {"AWS": "*"},
+          "Action": ["s3:GetObject"],
+          "Resource": [`arn:aws:s3:::${bucketName}/*`]
+        },
+        {
+          "Effect": "Allow",
+          "Principal": {"AWS": "*"},
+          "Action": ["s3:ListBucket"],
+          "Resource": [`arn:aws:s3:::${bucketName}`]
+        }
+      ]
+    };
+
+    try {
+      await this.client.setBucketPolicy(bucketName, JSON.stringify(policy));
+      logger.db(`Bucket policy set to public read for: ${bucketName}`);
+    } catch (error) {
+      logger.error(`Failed to set bucket policy for ${bucketName}:`, error);
+      throw error;
+    }
+  }
+
+  // 确保存储桶存在
+  async ensureBucketExists(bucketName: string = this.bucketName) {
+    try {
+      const exists = await this.client.bucketExists(bucketName);
+      if (!exists) {
+        await this.client.makeBucket(bucketName);
+        await this.setPublicReadPolicy(bucketName);
+        logger.db(`Created new bucket: ${bucketName}`);
+      }
+      return true;
+    } catch (error) {
+      logger.error(`Failed to ensure bucket exists: ${bucketName}`, error);
+      throw error;
+    }
+  }
+
+  // 生成上传策略
+  async generateUploadPolicy(fileKey: string) {
+    await this.ensureBucketExists();
+    
+    const expiresAt = new Date(Date.now() + 3600 * 1000);
+    const policy = this.client.newPostPolicy();
+    policy.setBucket(this.bucketName);
+    
+    policy.setKey(fileKey);
+    policy.setExpires(expiresAt);
+
+    const { postURL, formData } = await this.client.presignedPostPolicy(policy);
+
+    return {
+      'x-amz-algorithm': formData['x-amz-algorithm'],
+      'x-amz-credential': formData['x-amz-credential'],
+      'x-amz-date': formData['x-amz-date'],
+      'x-amz-security-token': formData['x-amz-security-token'] || undefined,
+      policy: formData['policy'],
+      'x-amz-signature': formData['x-amz-signature'],
+      host: postURL,
+      key: fileKey,
+      bucket: this.bucketName,
+    };
+  }
+
+  // 生成文件访问URL
+  getFileUrl(bucketName: string, fileKey: string) {
+    const protocol = process.env.MINIO_USE_SSL !== 'false' ? 'https' : 'http';
+    const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
+    return `${protocol}://${process.env.MINIO_HOST}${port}/${bucketName}/${fileKey}`;
+  }
+
+  // 生成预签名文件访问URL(用于私有bucket)
+  async getPresignedFileUrl(bucketName: string, fileKey: string, expiresInSeconds = 3600) {
+    try {
+      const url = await this.client.presignedGetObject(bucketName, fileKey, expiresInSeconds);
+      logger.db(`Generated presigned URL for ${bucketName}/${fileKey}, expires in ${expiresInSeconds}s`);
+      return url;
+    } catch (error) {
+      logger.error(`Failed to generate presigned URL for ${bucketName}/${fileKey}:`, error);
+      throw error;
+    }
+  }
+
+  // 生成预签名文件下载URL(带Content-Disposition头)
+  async getPresignedFileDownloadUrl(bucketName: string, fileKey: string, filename: string, expiresInSeconds = 3600) {
+    try {
+      const url = await this.client.presignedGetObject(
+        bucketName,
+        fileKey,
+        expiresInSeconds,
+        {
+          'response-content-disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
+          'response-content-type': 'application/octet-stream'
+        }
+      );
+      logger.db(`Generated presigned download URL for ${bucketName}/${fileKey}, filename: ${filename}`);
+      return url;
+    } catch (error) {
+      logger.error(`Failed to generate presigned download URL for ${bucketName}/${fileKey}:`, error);
+      throw error;
+    }
+  }
+
+  // 创建分段上传会话
+  async createMultipartUpload(bucketName: string, objectName: string) {
+    try {
+      const uploadId = await this.client.initiateNewMultipartUpload(bucketName, objectName, {});
+      logger.db(`Created multipart upload for ${objectName} with ID: ${uploadId}`);
+      return uploadId;
+    } catch (error) {
+      logger.error(`Failed to create multipart upload for ${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 生成分段上传预签名URL
+  async generateMultipartUploadUrls(
+    bucketName: string,
+    objectName: string,
+    uploadId: string,
+    partCount: number,
+    expiresInSeconds = 3600
+  ) {
+    try {
+      const partUrls = [];
+      for (let partNumber = 1; partNumber <= partCount; partNumber++) {
+        const url = await this.client.presignedUrl(
+          'put',
+          bucketName,
+          objectName,
+          expiresInSeconds,
+          {
+            uploadId,
+            partNumber: partNumber.toString()
+          }
+        );
+        partUrls.push(url);
+      }
+      return partUrls;
+    } catch (error) {
+      logger.error(`Failed to generate multipart upload URLs for ${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 完成分段上传
+  async completeMultipartUpload(
+    bucketName: string,
+    objectName: string,
+    uploadId: string,
+    parts: { ETag: string; PartNumber: number }[]
+  ): Promise<{ size: number }> {
+    try {
+      await this.client.completeMultipartUpload(
+        bucketName,
+        objectName,
+        uploadId,
+        parts.map(p => ({ part: p.PartNumber, etag: p.ETag }))
+      );
+      logger.db(`Completed multipart upload for ${objectName} with ID: ${uploadId}`);
+      
+      // 获取对象信息以获取文件大小
+      const stat = await this.client.statObject(bucketName, objectName);
+      return { size: stat.size };
+    } catch (error) {
+      logger.error(`Failed to complete multipart upload for ${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 上传文件
+  async createObject(bucketName: string, objectName: string, fileContent: Buffer, contentType: string = 'application/octet-stream') {
+    try {
+      await this.ensureBucketExists(bucketName);
+      await this.client.putObject(bucketName, objectName, fileContent, fileContent.length, {
+        'Content-Type': contentType
+      });
+      logger.db(`Created object: ${bucketName}/${objectName}`);
+      return this.getFileUrl(bucketName, objectName);
+    } catch (error) {
+      logger.error(`Failed to create object ${bucketName}/${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 检查文件是否存在
+  async objectExists(bucketName: string, objectName: string): Promise<boolean> {
+    try {
+      await this.client.statObject(bucketName, objectName);
+      return true;
+    } catch (error) {
+      if ((error as Error).message.includes('not found')) {
+        return false;
+      }
+      logger.error(`Error checking existence of object ${bucketName}/${objectName}:`, error);
+      throw error;
+    }
+  }
+
+  // 删除文件
+  async deleteObject(bucketName: string, objectName: string) {
+    try {
+      await this.client.removeObject(bucketName, objectName);
+      logger.db(`Deleted object: ${bucketName}/${objectName}`);
+    } catch (error) {
+      logger.error(`Failed to delete object ${bucketName}/${objectName}:`, error);
+      throw error;
+    }
+  }
+}

+ 27 - 0
src/server/modules/users/role.schema.ts

@@ -0,0 +1,27 @@
+import { z } from '@hono/zod-openapi';
+
+export type Permission = string;
+
+export const RoleSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '角色ID',
+    example: 1
+  }),
+  name: z.string().max(50).openapi({
+    description: '角色名称,唯一标识',
+    example: 'admin'
+  }),
+  description: z.string().max(500).nullable().openapi({
+    description: '角色描述',
+    example: '系统管理员角色'
+  }),
+  permissions: z.array(z.string()).min(1).openapi({
+    description: '角色权限列表',
+    example: ['user:create', 'user:delete']
+  }),
+  createdAt: z.date().openapi({ description: '创建时间' }),
+  updatedAt: z.date().openapi({ description: '更新时间' })
+});
+
+export const CreateRoleDto = RoleSchema.omit({ id: true , createdAt: true, updatedAt: true });
+export const UpdateRoleDto = RoleSchema.partial();

+ 9 - 83
src/server/modules/users/user.entity.ts

@@ -1,4 +1,4 @@
-import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
+import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable, CreateDateColumn, UpdateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
 import { Role, RoleSchema } from './role.entity';
 import { Role, RoleSchema } from './role.entity';
 import { z } from '@hono/zod-openapi';
 import { z } from '@hono/zod-openapi';
 import { DeleteStatus, DisabledStatus } from '@/share/types';
 import { DeleteStatus, DisabledStatus } from '@/share/types';
@@ -28,8 +28,14 @@ export class UserEntity {
   @Column({ name: 'name', type: 'varchar', length: 255, nullable: true, comment: '真实姓名' })
   @Column({ name: 'name', type: 'varchar', length: 255, nullable: true, comment: '真实姓名' })
   name!: string | null;
   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:禁用)' })
   @Column({ name: 'is_disabled', type: 'int', default: DisabledStatus.ENABLED, comment: '是否禁用(0:启用,1:禁用)' })
   isDisabled!: DisabledStatus;
   isDisabled!: DisabledStatus;
@@ -111,84 +117,4 @@ export const UserSchema = z.object({
   }),
   }),
   createdAt: z.date().openapi({ description: '创建时间' }),
   createdAt: z.date().openapi({ description: '创建时间' }),
   updatedAt: z.date().openapi({ description: '更新时间' })
   updatedAt: z.date().openapi({ description: '更新时间' })
-});
-
-export const CreateUserDto = z.object({
-  username: z.string().min(3).max(255).openapi({
-    example: 'admin',
-    description: '用户名,3-255个字符'
-  }),
-  password: z.string().min(6).max(255).openapi({
-    example: 'password123',
-    description: '密码,最少6位'
-  }),
-  phone: z.string().max(255).nullable().optional().openapi({
-    example: '13800138000',
-    description: '手机号'
-  }),
-  email: z.string().email().max(255).nullable().optional().openapi({
-    example: 'user@example.com',
-    description: '邮箱'
-  }),
-  nickname: z.string().max(255).nullable().optional().openapi({
-    example: '昵称',
-    description: '用户昵称'
-  }),
-  name: z.string().max(255).nullable().optional().openapi({
-    example: '张三',
-    description: '真实姓名'
-  }),
-  avatar: z.string().max(255).nullable().optional().openapi({
-    example: 'https://example.com/avatar.jpg',
-    description: '用户头像'
-  }),
-  isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED).openapi({
-    example: DisabledStatus.ENABLED,
-    description: '是否禁用(0:启用,1:禁用)'
-  }),
-  userType: z.enum([UserType.TEACHER, UserType.STUDENT]).default(UserType.STUDENT).openapi({
-    example: UserType.STUDENT,
-    description: '用户类型(teacher:老师,student:学生)'
-  }),
-  createdAt: z.date().openapi({ description: '创建时间' }),
-  updatedAt: z.date().openapi({ description: '更新时间' })
-});
-
-export const UpdateUserDto = z.object({
-  username: z.string().min(3).max(255).optional().openapi({
-    example: 'admin',
-    description: '用户名,3-255个字符'
-  }),
-  password: z.string().min(6).max(255).optional().openapi({
-    example: 'password123',
-    description: '密码,最少6位'
-  }),
-  phone: z.string().max(255).nullable().optional().openapi({
-    example: '13800138000',
-    description: '手机号'
-  }),
-  email: z.string().email().max(255).nullable().optional().openapi({
-    example: 'user@example.com',
-    description: '邮箱'
-  }),
-  nickname: z.string().max(255).nullable().optional().openapi({
-    example: '昵称',
-    description: '用户昵称'
-  }),
-  name: z.string().max(255).nullable().optional().openapi({
-    example: '张三',
-    description: '真实姓名'
-  }),
-  avatar: z.string().max(255).nullable().optional().openapi({
-    example: 'https://example.com/avatar.jpg',
-    description: '用户头像'
-  }),
-  isDisabled: z.number().int().min(0).max(1).optional().openapi({
-    example: DisabledStatus.ENABLED,
-    description: '是否禁用(0:启用,1:禁用)'
-  }),
-  userType: z.enum([UserType.TEACHER, UserType.STUDENT]).optional().openapi({
-    example: UserType.STUDENT,
-    description: '用户类型(teacher:老师,student:学生)'
-  })
 });
 });

+ 154 - 0
src/server/modules/users/user.schema.ts

@@ -0,0 +1,154 @@
+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';
+import { UserType } from './user.enum';
+
+// 基础用户 schema(包含所有字段)
+export const UserSchema = z.object({
+  id: z.number().int().positive().openapi({ description: '用户ID' }),
+  username: z.string().min(3, '用户名至少3个字符').max(255, '用户名最多255个字符').openapi({
+    example: 'admin',
+    description: '用户名,3-255个字符'
+  }),
+  password: z.string().min(6, '密码至少6位').max(255, '密码最多255位').openapi({
+    example: 'password123',
+    description: '密码,最少6位'
+  }),
+  phone: z.string().max(255, '手机号最多255个字符').nullable().openapi({
+    example: '13800138000',
+    description: '手机号'
+  }),
+  email: z.email('请输入正确的邮箱格式').max(255, '邮箱最多255个字符').nullable().openapi({
+    example: 'user@example.com',
+    description: '邮箱'
+  }),
+  nickname: z.string().max(255, '昵称最多255个字符').nullable().openapi({
+    example: '昵称',
+    description: '用户昵称'
+  }),
+  name: z.string().max(255, '姓名最多255个字符').nullable().openapi({
+    example: '张三',
+    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 })
+  }).nullable().optional().openapi({
+    description: '头像文件信息'
+  }),
+  isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED).openapi({
+    example: DisabledStatus.ENABLED,
+    description: '是否禁用(0:启用,1:禁用)'
+  }),
+  isDeleted: z.number().int().min(0).max(1).default(DeleteStatus.NOT_DELETED).openapi({
+    example: DeleteStatus.NOT_DELETED,
+    description: '是否删除(0:未删除,1:已删除)'
+  }),
+  roles: z.array(RoleSchema).optional().openapi({
+    example: [
+      {
+        id: 1,
+        name: 'admin',
+        description: '管理员',
+        permissions: ['user:create'],
+        createdAt: new Date(),
+        updatedAt: new Date()
+      }
+    ],
+    description: '用户角色列表'
+  }),
+  userType: z.enum([UserType.TEACHER, UserType.STUDENT]).default(UserType.STUDENT).openapi({
+    example: UserType.STUDENT,
+    description: '用户类型(teacher:老师,student:学生)'
+  }),
+  createdAt: z.coerce.date().openapi({ description: '创建时间' }),
+  updatedAt: z.coerce.date().openapi({ description: '更新时间' })
+});
+
+// 创建用户请求 schema
+export const CreateUserDto = z.object({
+  username: z.string().min(3, '用户名至少3个字符').max(255, '用户名最多255个字符').openapi({
+    example: 'admin',
+    description: '用户名,3-255个字符'
+  }),
+  password: z.string().min(6, '密码至少6位').max(255, '密码最多255位').openapi({
+    example: 'password123',
+    description: '密码,最少6位'
+  }),
+  phone: z.string().max(255, '手机号最多255个字符').nullable().optional().openapi({
+    example: '13800138000',
+    description: '手机号'
+  }),
+  email: z.email('请输入正确的邮箱格式').max(255, '邮箱最多255个字符').nullable().optional().openapi({
+    example: 'user@example.com',
+    description: '邮箱'
+  }),
+  nickname: z.string().max(255, '昵称最多255个字符').nullable().optional().openapi({
+    example: '昵称',
+    description: '用户昵称'
+  }),
+  name: z.string().max(255, '姓名最多255个字符').nullable().optional().openapi({
+    example: '张三',
+    description: '真实姓名'
+  }),
+  avatarFileId: z.number().int().positive().nullable().optional().openapi({
+    example: 1,
+    description: '头像文件ID'
+  }),
+  userType: z.enum([UserType.TEACHER, UserType.STUDENT]).default(UserType.STUDENT).openapi({
+    example: UserType.STUDENT,
+    description: '用户类型(teacher:老师,student:学生)'
+  }),
+  isDisabled: z.number().int().min(0, '状态值只能是0或1').max(1, '状态值只能是0或1').default(DisabledStatus.ENABLED).optional().openapi({
+    example: DisabledStatus.ENABLED,
+    description: '是否禁用(0:启用,1:禁用)'
+  })
+});
+
+// 更新用户请求 schema
+export const UpdateUserDto = z.object({
+  username: z.string().min(3, '用户名至少3个字符').max(255, '用户名最多255个字符').optional().openapi({
+    example: 'admin',
+    description: '用户名,3-255个字符'
+  }),
+  password: z.string().min(6, '密码至少6位').max(255, '密码最多255位').optional().openapi({
+    example: 'password123',
+    description: '密码,最少6位'
+  }),
+  phone: z.string().max(255, '手机号最多255个字符').nullable().optional().openapi({
+    example: '13800138000',
+    description: '手机号'
+  }),
+  email: z.email('请输入正确的邮箱格式').max(255, '邮箱最多255个字符').nullable().optional().openapi({
+    example: 'user@example.com',
+    description: '邮箱'
+  }),
+  nickname: z.string().max(255, '昵称最多255个字符').nullable().optional().openapi({
+    example: '昵称',
+    description: '用户昵称'
+  }),
+  name: z.string().max(255, '姓名最多255个字符').nullable().optional().openapi({
+    example: '张三',
+    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,
+    description: '是否禁用(0:启用,1:禁用)'
+  }),
+  userType: z.enum([UserType.TEACHER, UserType.STUDENT]).optional().openapi({
+    example: UserType.STUDENT,
+    description: '用户类型(teacher:老师,student:学生)'
+  })
+});