Browse Source

已完成用户角色系统核心功能实现

yourname 7 months ago
parent
commit
7bd08d1115

+ 64 - 0
client/admin/api/types.d.ts

@@ -0,0 +1,64 @@
+import { User } from '../../share/types.ts';
+
+declare module './users' {
+  interface UsersResponse {
+    data: User[];
+    pagination: {
+      total: number;
+      current: number;
+      pageSize: number;
+      totalPages: number;
+    };
+  }
+
+  interface UserResponse {
+    data: User;
+    message?: string;
+  }
+
+  interface UserCreateResponse {
+    message: string;
+    data: User;
+  }
+
+  interface UserUpdateResponse {
+    message: string;
+    data: User;
+  }
+
+  interface UserDeleteResponse {
+    message: string;
+    id: number;
+  }
+
+  interface UserConvertResponse {
+    message: string;
+    data: User;
+  }
+
+  export const UserAPI: {
+    getUsers: (params?: { page?: number; limit?: number; search?: string }) => Promise<UsersResponse>;
+    getUser: (userId: number) => Promise<UserResponse>;
+    createUser: (userData: Partial<User>) => Promise<UserCreateResponse>;
+    updateUser: (userId: number, userData: Partial<User>) => Promise<UserUpdateResponse>;
+    deleteUser: (userId: number) => Promise<UserDeleteResponse>;
+    convertToStudent: (userId: number) => Promise<UserConvertResponse>;
+  };
+}
+
+declare module 'antd' {
+  interface SelectProps {
+    placeholder?: string;
+    children?: React.ReactNode;
+  }
+
+  interface SelectOptionProps {
+    value: string | number;
+    children: React.ReactNode;
+    disabled?: boolean;
+  }
+
+  export const Select: React.FC<SelectProps> & {
+    Option: React.FC<SelectOptionProps>;
+  };
+}

+ 14 - 0
client/admin/api/users.ts

@@ -31,6 +31,11 @@ interface UserDeleteResponse {
   id: number;
 }
 
+interface UserConvertResponse {
+  message: string;
+  data: User;
+}
+
 export const UserAPI = {
   getUsers: async (
     params?: { page?: number; limit?: number; search?: string },
@@ -81,4 +86,13 @@ export const UserAPI = {
       throw error;
     }
   },
+
+  convertToStudent: async (userId: number): Promise<UserConvertResponse> => {
+    try {
+      const response = await axios.post(`/users/${userId}/convert-to-student`);
+      return response.data;
+    } catch (error) {
+      throw error;
+    }
+  },
 };

+ 43 - 10
client/admin/pages_users.tsx

@@ -5,7 +5,7 @@ import {
 } from 'antd';
 import { useQuery } from '@tanstack/react-query';
 import dayjs from 'dayjs';
-import { UserAPI } from './api/index.ts';
+import * as UserAPI from './api/users.ts';
 
 const { Title } = Typography;
 
@@ -20,6 +20,7 @@ export const UsersPage = () => {
   const [modalTitle, setModalTitle] = useState('');
   const [editingUser, setEditingUser] = useState<any>(null);
   const [form] = Form.useForm();
+  const [convertingId, setConvertingId] = useState<number | null>(null);
 
   const { data: usersData, isLoading, refetch } = useQuery({
     queryKey: ['users', searchParams],
@@ -104,6 +105,21 @@ export const UsersPage = () => {
       message.error('删除失败,请重试');
     }
   };
+
+  // 处理转为学员
+  const handleConvertToStudent = async (id: number) => {
+    setConvertingId(id);
+    try {
+      await UserAPI.convertToStudent(id);
+      message.success('用户已转为学员');
+      refetch(); // 刷新用户列表
+    } catch (error) {
+      console.error('转为学员失败:', error);
+      message.error('操作失败,请重试');
+    } finally {
+      setConvertingId(null);
+    }
+  };
   
   const columns = [
     {
@@ -137,6 +153,12 @@ export const UsersPage = () => {
       key: 'created_at',
       render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
     },
+    {
+      title: '有效期至',
+      dataIndex: 'expires_at',
+      key: 'expires_at',
+      render: (date: string) => date ? dayjs(date).format('YYYY-MM-DD') : '永久',
+    },
     {
       title: '操作',
       key: 'action',
@@ -145,16 +167,27 @@ export const UsersPage = () => {
           <Button type="link" onClick={() => showEditModal(record)}>
             编辑
           </Button>
-          <Popconfirm
-            title="确定要删除此用户吗?"
-            onConfirm={() => handleDelete(record.id)}
-            okText="确定"
-            cancelText="取消"
-          >
-            <Button type="link" danger>
-              删除
+          {record.role === 'admin' && (
+            <Popconfirm
+              title="确定要删除此用户吗?"
+              onConfirm={() => handleDelete(record.id)}
+              okText="确定"
+              cancelText="取消"
+            >
+              <Button type="link" danger>
+                删除
+              </Button>
+            </Popconfirm>
+          )}
+          {record.role !== 'student' && (
+            <Button
+              type="link"
+              onClick={() => handleConvertToStudent(record.id)}
+              loading={convertingId === record.id}
+            >
+              转为学员
             </Button>
-          </Popconfirm>
+          )}
         </Space>
       ),
     },

+ 1 - 0
deno.lock

@@ -449,6 +449,7 @@
     "https://deno.land/std@0.217.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd",
     "https://deno.land/std@0.217.0/assert/unreachable.ts": "38cfecb95d8b06906022d2f9474794fca4161a994f83354fd079cac9032b5145",
     "https://deno.land/std@0.217.0/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a",
+    "https://deno.land/x/croner@7.0.0/dist/croner.js": "a645901c5e55ba3857e93cbc66c46cffbe984c10ec555149ae0502621898d727",
     "https://deno.land/x/deno_dom@v0.1.45/build/deno-wasm/deno-wasm.js": "d6841a06342eb6a2798ef28de79ad69c0f2fa349fa04d3ca45e5fcfbf50a9340",
     "https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm.ts": "a33d160421bbb6e3104285ea5ebf33352b7ad50d82ea8765e3cf65f972b25119",
     "https://deno.land/x/deno_dom@v0.1.45/src/api.ts": "0ff5790f0a3eeecb4e00b7d8fbfa319b165962cf6d0182a65ba90f158d74f7d7",

+ 4 - 0
server/app.tsx

@@ -43,6 +43,10 @@ log.auth.enabled = true
 log.api.enabled = true
 log.debug.enabled = true
 
+// 初始化学员有效期检查服务
+import { initUserExpiryCheck } from './services/userExpiryCheck.ts'
+initUserExpiryCheck()
+
 
 interface EsmScriptConfig {
   src: string

+ 10 - 0
server/middlewares.ts

@@ -3,6 +3,16 @@ import { cors } from 'hono/cors'
 import type { Context as HonoContext } from 'hono'
 import { Auth } from '@d8d-appcontainer/auth'
 import type { User as AuthUser } from '@d8d-appcontainer/auth'
+
+// 扩展AuthUser类型
+declare module '@d8d-appcontainer/auth' {
+  interface User {
+    id: number
+    username: string
+    role: 'admin' | 'student' | 'follower'
+    student_expires_at?: string | null
+  }
+}
 import { APIClient } from '@d8d-appcontainer/api'
 import type { SystemSettingRecord } from '../client/share/types.ts'
 import debug from "debug"

+ 7 - 0
server/migrations/001_createUsersTable.ts

@@ -14,6 +14,13 @@ const createUsersTable: MigrationLiveDefinition = {
       table.string('name');
       table.integer('is_disabled').defaultTo(DeleteStatus.NOT_DELETED);
       table.integer('is_deleted').defaultTo(DeleteStatus.NOT_DELETED);
+      
+      // 用户角色: teacher(教师)/admin(管理员)/student(学生)/fan(粉丝)
+      table.enum('role', ['teacher', 'admin', 'student', 'fan']).notNullable().defaultTo('student');
+      
+      // 账户有效期,为空表示永久有效
+      table.timestamp('valid_until').nullable();
+      
       table.timestamps(true, true);
       
       // 添加索引

+ 114 - 0
server/routes_users.ts

@@ -306,5 +306,119 @@ export function createUserRoutes(withAuth: WithAuth) {
     }
   })
 
+  /**
+   * 将粉丝用户转换为学员并设置有效期
+   * @param {number} id - 用户ID
+   * @param {string} expiresAt - 有效期截止日期 (ISO格式)
+   * @returns {object} 转换后的用户信息
+   */
+  usersRoutes.post('/:id/convert-to-student', withAuth, async (c) => {
+    try {
+      const user = c.get('user')!
+      
+      // 检查是否为管理员
+      if (user.role !== 'admin') {
+        return c.json({ error: '无权执行此操作' }, 403)
+      }
+
+      const id = Number(c.req.param('id'))
+      if (!id || isNaN(id)) {
+        return c.json({ error: '无效的用户ID' }, 400)
+      }
+
+      const apiClient = c.get('apiClient')
+      const body = await c.req.json()
+      
+      // 验证必填字段
+      const { expiresAt } = body
+      if (!expiresAt) {
+        return c.json({ error: '缺少有效期参数' }, 400)
+      }
+
+      // 检查用户是否存在且为粉丝
+      const existingUser = await apiClient.database.table('users')
+        .where('id', id)
+        .first()
+      
+      if (!existingUser) {
+        return c.json({ error: '用户不存在' }, 404)
+      }
+
+      if (existingUser.role !== 'follower') {
+        return c.json({ error: '只能将粉丝转为学员' }, 400)
+      }
+
+      // 更新用户角色和有效期
+      await apiClient.database.table('users')
+        .where('id', id)
+        .update({
+          role: 'student',
+          student_expires_at: expiresAt,
+          updated_at: new Date()
+        })
+
+      const updatedUser = await apiClient.database.table('users')
+        .where('id', id)
+        .select('id', 'username', 'nickname', 'role', 'student_expires_at')
+        .first()
+
+      return c.json({
+        data: updatedUser,
+        message: '用户已成功转为学员'
+      })
+    } catch (error) {
+      console.error('转换用户角色失败:', error)
+      return c.json({ error: '转换用户角色失败' }, 500)
+    }
+  })
+
+  /**
+   * 检查并转换过期学员
+   * @returns {object} 转换结果统计
+   */
+  usersRoutes.get('/check-expired', withAuth, async (c) => {
+    try {
+      const apiClient = c.get('apiClient')
+      const now = new Date().toISOString()
+      
+      // 查找所有已过期的学员
+      const expiredStudents = await apiClient.database.table('users')
+        .where('role', 'student')
+        .where('student_expires_at', '<', now)
+        .select('id', 'username', 'student_expires_at')
+      
+      if (expiredStudents.length === 0) {
+        return c.json({
+          data: {
+            count: 0,
+            users: []
+          },
+          message: '没有过期学员需要转换'
+        })
+      }
+
+      // 批量转换过期学员为粉丝
+      const userIds = expiredStudents.map(user => user.id)
+      await apiClient.database.table('users')
+        .whereIn('id', userIds)
+        .update({
+          role: 'follower',
+          student_expires_at: null,
+          updated_at: new Date()
+        })
+
+      return c.json({
+        data: {
+          count: expiredStudents.length,
+          users: expiredStudents
+        },
+        message: '过期学员已成功转为粉丝'
+      })
+    } catch (error) {
+      console.error('检查过期学员失败:', error)
+      return c.json({ error: '检查过期学员失败' }, 500)
+    }
+  })
+
   return usersRoutes
 }

+ 37 - 0
server/services/userExpiryCheck.ts

@@ -0,0 +1,37 @@
+import { Cron } from "https://deno.land/x/croner@7.0.0/dist/croner.js"
+import debug from "debug"
+
+const log = debug('service:user-expiry-check')
+
+interface UserExpiryCheckConfig {
+  cronSchedule: string
+  endpoint: string
+}
+
+const defaultConfig: UserExpiryCheckConfig = {
+  cronSchedule: '0 0 * * *', // 每天凌晨执行
+  endpoint: '/api/users/check-expired'
+}
+
+export function initUserExpiryCheck(config: Partial<UserExpiryCheckConfig> = {}) {
+  const finalConfig = { ...defaultConfig, ...config }
+  
+  try {
+    new Cron(finalConfig.cronSchedule, async () => {
+      log('开始执行学员有效期检查任务')
+      
+      try {
+        const response = await fetch(finalConfig.endpoint)
+        const data = await response.json()
+        log('学员有效期检查完成,结果: %o', data)
+      } catch (error) {
+        log('调用检查接口失败: %o', error)
+      }
+    })
+    
+    log('学员有效期定时检查服务已启动')
+  } catch (error) {
+    log('定时任务初始化失败: %o', error)
+    throw error
+  }
+}