feat(admin): Add user management and upgrade to module permission system

Features - User Management (Phase 4.1):
- Database: Add user_modules table for fine-grained module permissions
- Database: Add 4 user permissions (view/create/edit/delete) to role_permissions
- Backend: UserService (780 lines) - CRUD with tenant isolation
- Backend: UserController + UserRoutes (648 lines) - 13 API endpoints
- Backend: Batch import users from Excel
- Frontend: UserListPage (412 lines) - list/filter/search/pagination
- Frontend: UserFormPage (341 lines) - create/edit with module config
- Frontend: UserDetailPage (393 lines) - details/tenant/module management
- Frontend: 3 modal components (592 lines) - import/assign/configure
- API: GET/POST/PUT/DELETE /api/admin/users/* endpoints

Architecture Upgrade - Module Permission System:
- Backend: Add getUserModules() method in auth.service
- Backend: Login API returns modules array in user object
- Frontend: AuthContext adds hasModule() method
- Frontend: Navigation filters modules based on user.modules
- Frontend: RouteGuard checks requiredModule instead of requiredVersion
- Frontend: Remove deprecated version-based permission system
- UX: Only show accessible modules in navigation (clean UI)
- UX: Smart redirect after login (avoid 403 for regular users)

Fixes:
- Fix UTF-8 encoding corruption in ~100 docs files
- Fix pageSize type conversion in userService (String to Number)
- Fix authUser undefined error in TopNavigation
- Fix login redirect logic with role-based access check
- Update Git commit guidelines v1.2 with UTF-8 safety rules

Database Changes:
- CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled)
- ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code)
- INSERT 4 permissions + role assignments
- UPDATE PUBLIC tenant with 8 module subscriptions

Technical:
- Backend: 5 new files (~2400 lines)
- Frontend: 10 new files (~2500 lines)
- Docs: 1 development record + 2 status updates + 1 guideline update
- Total: ~4900 lines of code

Status: User management 100% complete, module permission system operational
This commit is contained in:
2026-01-16 13:42:10 +08:00
parent 98d862dbd4
commit 66255368b7
560 changed files with 70424 additions and 52353 deletions

View File

@@ -169,6 +169,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
return roles.includes(user.role);
}, [user]);
/**
* 检查模块权限
*/
const hasModule = useCallback((moduleCode: string): boolean => {
if (!user) return false;
// SUPER_ADMIN 拥有所有模块权限
if (user.role === 'SUPER_ADMIN') return true;
return user.modules?.includes(moduleCode) || false;
}, [user]);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
@@ -182,6 +192,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
refreshToken,
hasPermission,
hasRole,
hasModule, // 新增
};
return (
@@ -210,3 +221,5 @@ export { AuthContext };

View File

@@ -246,3 +246,5 @@ export async function logout(): Promise<void> {

View File

@@ -12,3 +12,5 @@ export * from './api';

View File

@@ -36,3 +36,5 @@ export async function fetchUserModules(): Promise<string[]> {

View File

@@ -28,6 +28,7 @@ export interface AuthUser {
departmentName?: string | null;
isDefaultPassword: boolean;
permissions: string[];
modules: string[]; // 用户可访问的模块代码列表(如 ['AIA', 'PKB', 'RVW']
}
/** Token信息 */
@@ -97,6 +98,8 @@ export interface AuthContextType extends AuthState {
hasPermission: (permission: string) => boolean;
/** 检查角色 */
hasRole: (...roles: UserRole[]) => boolean;
/** 检查模块权限 */
hasModule: (moduleCode: string) => boolean;
}
@@ -105,3 +108,5 @@ export interface AuthContextType extends AuthState {

View File

@@ -1,18 +1,15 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Dropdown, Avatar, Tooltip } from 'antd'
import { Dropdown, Avatar } from 'antd'
import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
LockOutlined,
ControlOutlined,
BankOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { getAvailableModulesByCode } from '../modules/moduleRegistry'
import { fetchUserModules } from '../auth/moduleApi'
import { usePermission } from '../permission'
import { MODULES } from '../modules/moduleRegistry'
import { useAuth } from '../auth'
import type { ModuleDefinition } from '../modules/types'
@@ -29,24 +26,15 @@ import type { ModuleDefinition } from '../modules/types'
const TopNavigation = () => {
const navigate = useNavigate()
const location = useLocation()
const { user: authUser, logout: authLogout } = useAuth()
const { user, checkModulePermission, logout } = usePermission()
const [availableModules, setAvailableModules] = useState<ModuleDefinition[]>([])
const { user, logout: authLogout, hasModule } = useAuth()
// 加载用户可访问的模块
useEffect(() => {
const loadModules = async () => {
try {
const moduleCodes = await fetchUserModules()
const modules = getAvailableModulesByCode(moduleCodes)
setAvailableModules(modules)
} catch (error) {
console.error('加载模块权限失败', error)
setAvailableModules([])
}
}
loadModules()
}, [authUser])
// 根据用户模块权限过滤可显示的模块
const availableModules = MODULES.filter(module => {
// 没有 moduleCode 的模块跳过(占位模块)
if (!module.moduleCode) return false;
// 检查用户是否有权限访问
return hasModule(module.moduleCode);
});
// 获取当前激活的模块
const activeModule = availableModules.find(module =>
@@ -54,7 +42,7 @@ const TopNavigation = () => {
)
// 检查用户权限,决定显示哪些切换入口
const userRole = authUser?.role || ''
const userRole = user?.role || ''
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole)
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole)
@@ -97,7 +85,6 @@ const TopNavigation = () => {
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
authLogout()
logout()
navigate('/login')
} else if (key === 'switch-admin') {
navigate('/admin/dashboard')
@@ -108,16 +95,6 @@ const TopNavigation = () => {
}
}
// 处理模块点击(检查权限)
const handleModuleClick = (modulePath: string, requiredVersion?: string) => {
if (!checkModulePermission(requiredVersion as any)) {
// 理论上不会到这里,因为已经过滤了
console.warn('权限不足,无法访问该模块')
return
}
navigate(modulePath)
}
return (
<div className="h-16 bg-white border-b border-gray-200 px-6 flex items-center justify-between">
{/* Logo */}
@@ -129,53 +106,42 @@ const TopNavigation = () => {
<span className="text-xl font-bold text-blue-600">AI临床研究平台</span>
</div>
{/* 导航菜单 - 根据用户权限动态显示 ⭐ Week 2 Day 7 更新 */}
{/* 导航菜单 - 只显示有权限的模块 ⭐ 2026-01-16 更新为模块权限系统 */}
<div className="flex items-center gap-2">
{availableModules.map(module => {
const hasPermission = checkModulePermission(module.requiredVersion as any)
const isActive = activeModule?.id === module.id
return (
<Tooltip
<div
key={module.id}
title={!hasPermission ? `需要${module.requiredVersion}版本` : ''}
onClick={() => navigate(module.path)}
className={`
px-4 py-2 rounded-md transition-all cursor-pointer
${isActive
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50 hover:text-blue-600'
}
`}
>
<div
onClick={() => hasPermission && handleModuleClick(module.path, module.requiredVersion)}
className={`
px-4 py-2 rounded-md transition-all
${!hasPermission
? 'text-gray-400 cursor-not-allowed opacity-50'
: isActive
? 'bg-blue-50 text-blue-600 font-semibold cursor-pointer'
: 'text-gray-600 hover:bg-gray-50 hover:text-blue-600 cursor-pointer'
}
`}
>
<span className="flex items-center gap-2">
{!hasPermission && <LockOutlined className="text-xs" />}
{module.name}
</span>
</div>
</Tooltip>
{module.name}
</div>
)
})}
</div>
{/* 用户菜单 - 显示真实用户信息 ⭐ Week 2 Day 7 更新 */}
{/* 用户菜单 - 显示真实用户信息 */}
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
placement="bottomRight"
>
<div className="flex items-center gap-2 cursor-pointer px-3 py-2 rounded-md hover:bg-gray-50">
<Avatar
src={user?.avatar}
icon={<UserOutlined />}
size="small"
/>
<div className="flex flex-col">
<span className="text-gray-700 text-sm">{user?.name || '访客'}</span>
<span className="text-xs text-gray-400">{user?.version || 'basic'}</span>
<span className="text-xs text-gray-400">{user?.modules?.length || 0} </span>
</div>
</div>
</Dropdown>

View File

@@ -1,9 +1,8 @@
import { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth'
import { usePermission } from '../permission'
import PermissionDenied from './PermissionDenied'
import type { UserVersion } from '../permission'
import { Result, Button } from 'antd'
import { LockOutlined } from '@ant-design/icons'
/**
* 路由守卫组件
@@ -13,50 +12,50 @@ import type { UserVersion } from '../permission'
*
* 检查顺序:
* 1. 检查是否已登录(未登录→重定向到登录页)
* 2. 检查权限等级(无权限→显示PermissionDenied
* 2. 检查模块权限(无权限→显示权限不足页面
* 3. 有权限→渲染子组件
*
* @version 2026-01-16 更新为模块权限系统
*/
interface RouteGuardProps {
/** 子组件 */
children: ReactNode
/** 所需权限等级 */
requiredVersion?: UserVersion
/** 所需模块代码(如 'AIA', 'PKB' */
requiredModule?: string
/** 模块名称(用于显示友好提示) */
moduleName?: string
/** 是否重定向到首页(默认显示无权限页面) */
redirectToHome?: boolean
/** 兼容旧参数(废弃) */
requiredVersion?: any
}
const RouteGuard = ({
children,
requiredVersion,
requiredModule,
moduleName,
redirectToHome = false,
}: RouteGuardProps) => {
const location = useLocation()
const { isAuthenticated, isLoading } = useAuth()
const { user, checkModulePermission } = usePermission()
const { isAuthenticated, isLoading, user, hasModule } = useAuth()
// 加载中显示空白或可以显示loading
// 加载中显示空白
if (isLoading) {
return null
}
// 1. 检查是否登录
if (!isAuthenticated) {
// 保存当前路径,登录后跳转回来
return <Navigate to="/login" state={{ from: location }} replace />
}
// 2. 检查权限等级
const hasPermission = checkModulePermission(requiredVersion)
if (!hasPermission) {
console.log('🔒 权限不足:', {
module: moduleName,
requiredVersion,
currentVersion: user?.version,
// 2. 检查模块权限(如果指定了 requiredModule
if (requiredModule && !hasModule(requiredModule)) {
console.log('🔒 模块权限不足:', {
module: moduleName || requiredModule,
requiredModule,
userModules: user?.modules,
userId: user?.id,
timestamp: new Date().toISOString(),
})
@@ -65,12 +64,21 @@ const RouteGuard = ({
return <Navigate to="/" replace />
}
// 显示权限不足页面
return (
<PermissionDenied
moduleName={moduleName}
requiredVersion={requiredVersion}
currentVersion={user?.version}
/>
<div style={{ padding: '100px 0', textAlign: 'center' }}>
<Result
status="403"
icon={<LockOutlined style={{ fontSize: 72, color: '#faad14' }} />}
title="权限不足"
subTitle={`您暂无访问 "${moduleName || requiredModule}" 模块的权限,请联系管理员开通。`}
extra={
<Button type="primary" onClick={() => window.history.back()}>
</Button>
}
/>
</div>
)
}