2
0
Quellcode durchsuchen

✨ feat(user): 添加学员管理功能

- 新增学员设置与撤销功能,支持设置有效期
- 在用户列表添加学员状态和有效期显示列
- 实现学员有效期开始/结束时间日期选择器
- 添加学员状态切换按钮和操作对话框
- 增加学员相关API接口和数据模型字段

🐛 fix(user): 修复用户类型状态显示问题

- 优化用户类型显示样式,学员状态使用紫色标识
- 修复用户状态标签样式不一致问题

♻️ refactor(user): 重构用户服务层代码

- 提取学员管理相关逻辑到独立方法
- 优化用户更新操作的数据处理流程
- 完善用户类型转换的业务逻辑校验
yourname vor 6 Monaten
Ursprung
Commit
fedeaf0291

+ 175 - 2
src/client/admin/pages/Users.tsx

@@ -5,7 +5,7 @@ import { zhCN } from 'date-fns/locale';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { useForm } from 'react-hook-form';
 import { toast } from 'sonner';
-import { Plus, Search, Edit, Trash2, Eye } from 'lucide-react';
+import { Plus, Search, Edit, Trash2, Eye, Calendar, UserCheck, UserX } from 'lucide-react';
 
 import { userClient } from '@/client/api';
 import type { InferResponseType, InferRequestType } from 'hono/client';
@@ -46,6 +46,10 @@ export const UsersPage = () => {
   const [editingUser, setEditingUser] = useState<any>(null);
   const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
   const [userToDelete, setUserToDelete] = useState<number | null>(null);
+  const [traineeDialogOpen, setTraineeDialogOpen] = useState(false);
+  const [selectedUser, setSelectedUser] = useState<any>(null);
+  const [traineeValidFrom, setTraineeValidFrom] = useState<string>('');
+  const [traineeValidTo, setTraineeValidTo] = useState<string>('');
 
   // 创建表单
   const createForm = useForm<CreateUserRequest>({
@@ -192,6 +196,84 @@ export const UsersPage = () => {
     }
   };
 
+  // 打开设置学员对话框
+  const showSetTraineeDialog = (user: any) => {
+    setSelectedUser(user);
+    setTraineeValidFrom(user.traineeValidFrom ? new Date(user.traineeValidFrom).toISOString().split('T')[0] : '');
+    setTraineeValidTo(user.traineeValidTo ? new Date(user.traineeValidTo).toISOString().split('T')[0] : '');
+    setTraineeDialogOpen(true);
+  };
+
+  // 打开撤销学员对话框
+  const showRemoveTraineeDialog = (user: any) => {
+    setSelectedUser(user);
+    setTraineeDialogOpen(true);
+  };
+
+  // 处理设置学员
+  const handleSetTrainee = async () => {
+    if (!selectedUser || !traineeValidFrom || !traineeValidTo) return;
+    
+    try {
+      const validFrom = new Date(traineeValidFrom);
+      const validTo = new Date(traineeValidTo);
+      
+      if (validFrom >= validTo) {
+        toast.error('有效期结束时间必须晚于开始时间');
+        return;
+      }
+
+      const res = await userClient[':id']['$put']({
+        param: { id: selectedUser.id.toString() },
+        json: {
+          userType: UserType.TRAINEE,
+          traineeValidFrom: validFrom.toISOString(),
+          traineeValidTo: validTo.toISOString()
+        }
+      });
+      
+      if (res.status !== 200) {
+        throw new Error('设置学员失败');
+      }
+      
+      toast.success('设置学员成功');
+      setTraineeDialogOpen(false);
+      setSelectedUser(null);
+      setTraineeValidFrom('');
+      setTraineeValidTo('');
+      refetch();
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : '设置失败,请重试');
+    }
+  };
+
+  // 处理撤销学员
+  const handleRemoveTrainee = async () => {
+    if (!selectedUser) return;
+    
+    try {
+      const res = await userClient[':id']['$put']({
+        param: { id: selectedUser.id.toString() },
+        json: {
+          userType: UserType.STUDENT,
+          traineeValidFrom: null,
+          traineeValidTo: null
+        }
+      });
+      
+      if (res.status !== 200) {
+        throw new Error('撤销学员失败');
+      }
+      
+      toast.success('撤销学员成功');
+      setTraineeDialogOpen(false);
+      setSelectedUser(null);
+      refetch();
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : '撤销失败,请重试');
+    }
+  };
+
   // 加载状态
   if (isLoading) {
     return (
@@ -265,6 +347,8 @@ export const UsersPage = () => {
                   <TableHead>邮箱</TableHead>
                   <TableHead>真实姓名</TableHead>
                   <TableHead>用户类型</TableHead>
+                  <TableHead>学员状态</TableHead>
+                  <TableHead>学员有效期</TableHead>
                   <TableHead>状态</TableHead>
                   <TableHead>创建时间</TableHead>
                   <TableHead className="text-right">操作</TableHead>
@@ -307,7 +391,24 @@ export const UsersPage = () => {
                       </Badge>
                     </TableCell>
                     <TableCell>
-                      <Badge 
+                      <Badge
+                        variant={user.userType === 'trainee' ? 'default' : 'secondary'}
+                        className={user.userType === 'trainee' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'}
+                      >
+                        {user.userType === 'trainee' ? '学员' : '非学员'}
+                      </Badge>
+                    </TableCell>
+                    <TableCell>
+                      {user.userType === 'trainee' && user.traineeValidFrom && user.traineeValidTo ? (
+                        <div className="text-sm">
+                          <div>{format(new Date(user.traineeValidFrom), 'yyyy-MM-dd')}</div>
+                          <div>至</div>
+                          <div>{format(new Date(user.traineeValidTo), 'yyyy-MM-dd')}</div>
+                        </div>
+                      ) : '-'}
+                    </TableCell>
+                    <TableCell>
+                      <Badge
                         variant={user.isDisabled === 0 ? 'default' : 'destructive'}
                         className={user.isDisabled === 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}
                       >
@@ -319,6 +420,27 @@ export const UsersPage = () => {
                     </TableCell>
                     <TableCell className="text-right">
                       <div className="flex justify-end gap-2">
+                        {user.userType === 'trainee' ? (
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => showRemoveTraineeDialog(user)}
+                            className="text-purple-600 hover:text-purple-800"
+                            title="撤销学员身份"
+                          >
+                            <UserX className="h-4 w-4" />
+                          </Button>
+                        ) : (
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => showSetTraineeDialog(user)}
+                            className="text-purple-600 hover:text-purple-800"
+                            title="设置为学员"
+                          >
+                            <UserCheck className="h-4 w-4" />
+                          </Button>
+                        )}
                         <Button
                           variant="ghost"
                           size="sm"
@@ -653,6 +775,57 @@ export const UsersPage = () => {
           </AlertDialogFooter>
         </AlertDialogContent>
       </AlertDialog>
+
+      {/* 学员设置对话框 */}
+      <Dialog open={traineeDialogOpen} onOpenChange={setTraineeDialogOpen}>
+        <DialogContent className="sm:max-w-[400px]">
+          <DialogHeader>
+            <DialogTitle>
+              {selectedUser?.userType === 'trainee' ? '撤销学员身份' : '设置为学员'}
+            </DialogTitle>
+            <DialogDescription>
+              {selectedUser?.userType === 'trainee'
+                ? '确定要撤销该用户的学员身份吗?'
+                : '请设置学员的有效期'}
+            </DialogDescription>
+          </DialogHeader>
+
+          {selectedUser?.userType !== 'trainee' && (
+            <div className="space-y-4">
+              <div>
+                <label className="text-sm font-medium">有效期开始时间</label>
+                <Input
+                  type="date"
+                  value={traineeValidFrom}
+                  onChange={(e) => setTraineeValidFrom(e.target.value)}
+                  className="mt-1"
+                />
+              </div>
+              <div>
+                <label className="text-sm font-medium">有效期结束时间</label>
+                <Input
+                  type="date"
+                  value={traineeValidTo}
+                  onChange={(e) => setTraineeValidTo(e.target.value)}
+                  className="mt-1"
+                />
+              </div>
+            </div>
+          )}
+
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setTraineeDialogOpen(false)}>
+              取消
+            </Button>
+            <Button
+              onClick={selectedUser?.userType === 'trainee' ? handleRemoveTrainee : handleSetTrainee}
+              className={selectedUser?.userType === 'trainee' ? 'bg-red-600 hover:bg-red-700' : ''}
+            >
+              {selectedUser?.userType === 'trainee' ? '撤销学员' : '设置为学员'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
     </div>
   );
 };

+ 6 - 0
src/server/modules/users/user.entity.ts

@@ -87,6 +87,12 @@ export class UserEntity {
   @Column({ name: 'wechat_country', type: 'varchar', length: 100, nullable: true, comment: '微信国家' })
   wechatCountry!: string | null;
 
+  @Column({ name: 'trainee_valid_from', type: 'timestamp', nullable: true, comment: '学员有效期开始时间' })
+  traineeValidFrom!: Date | null;
+
+  @Column({ name: 'trainee_valid_to', type: 'timestamp', nullable: true, comment: '学员有效期结束时间' })
+  traineeValidTo!: Date | null;
+
   constructor(partial?: Partial<UserEntity>) {
     Object.assign(this, partial);
   }

+ 26 - 2
src/server/modules/users/user.schema.ts

@@ -101,6 +101,14 @@ export const UserSchema = z.object({
     example: '中国',
     description: '微信国家'
   }),
+  traineeValidFrom: z.coerce.date().nullable().openapi({
+    example: '2024-01-01T00:00:00Z',
+    description: '学员有效期开始时间'
+  }),
+  traineeValidTo: z.coerce.date().nullable().openapi({
+    example: '2024-12-31T23:59:59Z',
+    description: '学员有效期结束时间'
+  }),
   createdAt: z.coerce.date().openapi({ description: '创建时间' }),
   updatedAt: z.coerce.date().openapi({ description: '更新时间' })
 });
@@ -142,7 +150,15 @@ export const CreateUserDto = z.object({
   isDisabled: z.number().int().min(0, '状态值只能是0或1').max(1, '状态值只能是0或1').default(DisabledStatus.ENABLED).optional().openapi({
     example: DisabledStatus.ENABLED,
     description: '是否禁用(0:启用,1:禁用)'
-  })
+  }),
+  traineeValidFrom: z.coerce.date().nullable().optional().openapi({
+    example: '2024-01-01T00:00:00Z',
+    description: '学员有效期开始时间'
+  }),
+  traineeValidTo: z.coerce.date().nullable().optional().openapi({
+    example: '2024-12-31T23:59:59Z',
+    description: '学员有效期结束时间'
+  }),
 });
 
 // 更新用户请求 schema
@@ -182,5 +198,13 @@ export const UpdateUserDto = z.object({
   userType: z.enum([UserType.TEACHER, UserType.STUDENT, UserType.TRAINEE]).optional().openapi({
     example: UserType.STUDENT,
     description: '用户类型(teacher:老师,student:学生,trainee:学员)'
-  })
+  }),
+  traineeValidFrom: z.coerce.date().nullable().optional().openapi({
+    example: '2024-01-01T00:00:00Z',
+    description: '学员有效期开始时间'
+  }),
+  traineeValidTo: z.coerce.date().nullable().optional().openapi({
+    example: '2024-12-31T23:59:59Z',
+    description: '学员有效期结束时间'
+  }),
 });

+ 99 - 0
src/server/modules/users/user.service.ts

@@ -3,6 +3,7 @@ import { UserEntity as User } from './user.entity';
 import * as bcrypt from 'bcrypt';
 import { Repository } from 'typeorm';
 import { Role } from './role.entity';
+import { UserType } from './user.enum';
 
 const SALT_ROUNDS = 10;
 
@@ -199,6 +200,104 @@ export class UserService {
     }
   }
 
+  /**
+   * 设置用户为学员并设置有效期
+   * @param userId 用户ID
+   * @param validFrom 有效期开始时间
+   * @param validTo 有效期结束时间
+   * @param operatorId 操作人ID
+   * @returns 更新后的用户信息
+   */
+  async setUserAsTrainee(userId: number, validFrom: Date, validTo: Date, operatorId: number): Promise<User | null> {
+    try {
+      const user = await this.getUserById(userId);
+      if (!user) return null;
+
+      const oldValues = {
+        userType: user.userType,
+        traineeValidFrom: user.traineeValidFrom,
+        traineeValidTo: user.traineeValidTo
+      };
+
+      const updateData = {
+        userType: UserType.TRAINEE,
+        traineeValidFrom: validFrom,
+        traineeValidTo: validTo
+      };
+
+      const updatedUser = await this.updateUser(userId, updateData);
+
+      return updatedUser;
+    } catch (error) {
+      console.error('Error setting user as trainee:', error);
+      throw new Error('Failed to set user as trainee');
+    }
+  }
+
+  /**
+   * 撤销用户学员身份
+   * @param userId 用户ID
+   * @param operatorId 操作人ID
+   * @returns 更新后的用户信息
+   */
+  async removeUserTraineeStatus(userId: number, operatorId: number): Promise<User | null> {
+    try {
+      const user = await this.getUserById(userId);
+      if (!user) return null;
+
+      const oldValues = {
+        userType: user.userType,
+        traineeValidFrom: user.traineeValidFrom,
+        traineeValidTo: user.traineeValidTo
+      };
+
+      const updateData = {
+        userType: UserType.STUDENT,
+        traineeValidFrom: null,
+        traineeValidTo: null
+      };
+
+      const updatedUser = await this.updateUser(userId, updateData);
+      
+      return updatedUser;
+    } catch (error) {
+      console.error('Error removing user trainee status:', error);
+      throw new Error('Failed to remove user trainee status');
+    }
+  }
+
+  /**
+   * 更新学员有效期
+   * @param userId 用户ID
+   * @param validFrom 有效期开始时间
+   * @param validTo 有效期结束时间
+   * @param operatorId 操作人ID
+   * @returns 更新后的用户信息
+   */
+  async updateTraineeValidity(userId: number, validFrom: Date, validTo: Date, operatorId: number): Promise<User | null> {
+    try {
+      const user = await this.getUserById(userId);
+      if (!user || user.userType !== 'trainee') return null;
+
+      const oldValues = {
+        traineeValidFrom: user.traineeValidFrom,
+        traineeValidTo: user.traineeValidTo
+      };
+
+      const updateData = {
+        traineeValidFrom: validFrom,
+        traineeValidTo: validTo
+      };
+
+      const updatedUser = await this.updateUser(userId, updateData);
+
+      return updatedUser;
+    } catch (error) {
+      console.error('Error updating trainee validity:', error);
+      throw new Error('Failed to update trainee validity');
+    }
+  }
+
   /**
    * 生成唯一的用户名,如果用户名已存在则添加随机数字
    * @param username 基础用户名