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:
@@ -94,6 +94,8 @@ vite.config.*.timestamp-*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ exec nginx -g 'daemon off;'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -217,6 +217,8 @@ http {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ import PromptEditorPage from './pages/admin/PromptEditorPage'
|
||||
import TenantListPage from './pages/admin/tenants/TenantListPage'
|
||||
import TenantDetailPage from './pages/admin/tenants/TenantDetailPage'
|
||||
import { MODULES } from './framework/modules/moduleRegistry'
|
||||
// 用户管理页面
|
||||
import UserListPage from './modules/admin/pages/UserListPage'
|
||||
import UserFormPage from './modules/admin/pages/UserFormPage'
|
||||
import UserDetailPage from './modules/admin/pages/UserDetailPage'
|
||||
|
||||
/**
|
||||
* 应用根组件
|
||||
@@ -67,14 +71,14 @@ function App() {
|
||||
{/* 首页 */}
|
||||
<Route index element={<HomePage />} />
|
||||
|
||||
{/* 动态加载模块路由 */}
|
||||
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
|
||||
{MODULES.map(module => (
|
||||
<Route
|
||||
key={module.id}
|
||||
path={`${module.path}/*`}
|
||||
element={
|
||||
<RouteGuard
|
||||
requiredVersion={module.requiredVersion}
|
||||
requiredModule={module.moduleCode}
|
||||
moduleName={module.name}
|
||||
>
|
||||
<module.component />
|
||||
@@ -94,7 +98,12 @@ function App() {
|
||||
{/* 租户管理 */}
|
||||
<Route path="tenants" element={<TenantListPage />} />
|
||||
<Route path="tenants/:id" element={<TenantDetailPage />} />
|
||||
<Route path="users" element={<div className="text-center py-20">🚧 用户管理页面开发中...</div>} />
|
||||
{/* 用户管理 */}
|
||||
<Route path="users" element={<UserListPage />} />
|
||||
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
||||
<Route path="users/:id" element={<UserDetailPage />} />
|
||||
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
||||
{/* 系统配置 */}
|
||||
<Route path="system" element={<div className="text-center py-20">🚧 系统配置页面开发中...</div>} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -47,3 +47,5 @@ export default apiClient;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -246,3 +246,5 @@ export async function logout(): Promise<void> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,5 @@ export * from './api';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -36,3 +36,5 @@ export async function fetchUserModules(): Promise<string[]> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
129
frontend-v2/src/modules/admin/api/userApi.ts
Normal file
129
frontend-v2/src/modules/admin/api/userApi.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 用户管理 API
|
||||
*/
|
||||
|
||||
import apiClient from '@/common/api/axios';
|
||||
import type {
|
||||
UserListItem,
|
||||
UserDetail,
|
||||
ListUsersParams,
|
||||
PaginatedResponse,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
AssignTenantRequest,
|
||||
UpdateUserModulesRequest,
|
||||
ImportUserRow,
|
||||
ImportResult,
|
||||
TenantOption,
|
||||
DepartmentOption,
|
||||
ModuleOption,
|
||||
} from '../types/user';
|
||||
|
||||
const BASE_URL = '/api/admin/users';
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
*/
|
||||
export async function listUsers(params: ListUsersParams): Promise<PaginatedResponse<UserListItem>> {
|
||||
const response = await apiClient.get<{ code: number; data: PaginatedResponse<UserListItem> }>(BASE_URL, { params });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*/
|
||||
export async function getUserById(id: string): Promise<UserDetail> {
|
||||
const response = await apiClient.get<{ code: number; data: UserDetail }>(`${BASE_URL}/${id}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
export async function createUser(data: CreateUserRequest): Promise<UserDetail> {
|
||||
const response = await apiClient.post<{ code: number; data: UserDetail }>(BASE_URL, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
*/
|
||||
export async function updateUser(id: string, data: UpdateUserRequest): Promise<UserDetail> {
|
||||
const response = await apiClient.put<{ code: number; data: UserDetail }>(`${BASE_URL}/${id}`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户状态(启用/禁用)
|
||||
*/
|
||||
export async function updateUserStatus(id: string, status: 'active' | 'disabled'): Promise<void> {
|
||||
await apiClient.put(`${BASE_URL}/${id}/status`, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
*/
|
||||
export async function resetUserPassword(id: string): Promise<void> {
|
||||
await apiClient.post(`${BASE_URL}/${id}/reset-password`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配租户给用户
|
||||
*/
|
||||
export async function assignTenantToUser(userId: string, data: AssignTenantRequest): Promise<void> {
|
||||
await apiClient.post(`${BASE_URL}/${userId}/tenants`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从租户移除用户
|
||||
*/
|
||||
export async function removeTenantFromUser(userId: string, tenantId: string): Promise<void> {
|
||||
await apiClient.delete(`${BASE_URL}/${userId}/tenants/${tenantId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户模块权限
|
||||
*/
|
||||
export async function updateUserModules(userId: string, data: UpdateUserModulesRequest): Promise<void> {
|
||||
await apiClient.put(`${BASE_URL}/${userId}/modules`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入用户
|
||||
*/
|
||||
export async function importUsers(users: ImportUserRow[], defaultTenantId?: string): Promise<ImportResult> {
|
||||
const response = await apiClient.post<{ code: number; data: ImportResult }>(`${BASE_URL}/import`, {
|
||||
users,
|
||||
defaultTenantId,
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有租户列表(用于下拉选择)
|
||||
*/
|
||||
export async function getTenantOptions(): Promise<TenantOption[]> {
|
||||
const response = await apiClient.get<{ code: number; data: TenantOption[] }>(`${BASE_URL}/options/tenants`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户的科室列表
|
||||
*/
|
||||
export async function getDepartmentOptions(tenantId: string): Promise<DepartmentOption[]> {
|
||||
const response = await apiClient.get<{ code: number; data: DepartmentOption[] }>(
|
||||
`${BASE_URL}/options/tenants/${tenantId}/departments`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户的模块列表
|
||||
*/
|
||||
export async function getModuleOptions(tenantId: string): Promise<ModuleOption[]> {
|
||||
const response = await apiClient.get<{ code: number; data: ModuleOption[] }>(
|
||||
`${BASE_URL}/options/tenants/${tenantId}/modules`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
168
frontend-v2/src/modules/admin/components/AssignTenantModal.tsx
Normal file
168
frontend-v2/src/modules/admin/components/AssignTenantModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 分配租户弹窗
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Select,
|
||||
Checkbox,
|
||||
Row,
|
||||
Col,
|
||||
message,
|
||||
Typography,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import * as userApi from '../api/userApi';
|
||||
import type { TenantOption, ModuleOption } from '../types/user';
|
||||
import { ROLE_DISPLAY_NAMES } from '../types/user';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface AssignTenantModalProps {
|
||||
visible: boolean;
|
||||
userId: string;
|
||||
existingTenantIds: string[];
|
||||
tenantOptions: TenantOption[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const AssignTenantModal: React.FC<AssignTenantModalProps> = ({
|
||||
visible,
|
||||
userId,
|
||||
existingTenantIds,
|
||||
tenantOptions,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>();
|
||||
const [moduleOptions, setModuleOptions] = useState<ModuleOption[]>([]);
|
||||
const [loadingModules, setLoadingModules] = useState(false);
|
||||
|
||||
// 可选的租户(排除已加入的)
|
||||
const availableTenants = tenantOptions.filter(
|
||||
(t) => !existingTenantIds.includes(t.id)
|
||||
);
|
||||
|
||||
// 当选择租户时加载模块选项
|
||||
useEffect(() => {
|
||||
if (selectedTenantId) {
|
||||
setLoadingModules(true);
|
||||
userApi.getModuleOptions(selectedTenantId)
|
||||
.then(setModuleOptions)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingModules(false));
|
||||
} else {
|
||||
setModuleOptions([]);
|
||||
}
|
||||
}, [selectedTenantId]);
|
||||
|
||||
// 重置表单
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.resetFields();
|
||||
setSelectedTenantId(undefined);
|
||||
setModuleOptions([]);
|
||||
}
|
||||
}, [visible, form]);
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async (values: any) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.assignTenantToUser(userId, {
|
||||
tenantId: values.tenantId,
|
||||
role: values.role,
|
||||
allowedModules: values.allowedModules?.length > 0 ? values.allowedModules : undefined,
|
||||
});
|
||||
message.success('分配成功');
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '分配失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 角色选项
|
||||
const roleOptions = Object.entries(ROLE_DISPLAY_NAMES).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="分配租户"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{ role: 'USER' }}
|
||||
>
|
||||
<Form.Item
|
||||
label="选择租户"
|
||||
name="tenantId"
|
||||
rules={[{ required: true, message: '请选择租户' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择租户"
|
||||
options={availableTenants.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.code})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelectedTenantId(value);
|
||||
form.setFieldsValue({ allowedModules: [] });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="在该租户内的角色"
|
||||
name="role"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select placeholder="请选择角色" options={roleOptions} />
|
||||
</Form.Item>
|
||||
|
||||
{selectedTenantId && (
|
||||
<Form.Item label="模块权限" name="allowedModules">
|
||||
<Spin spinning={loadingModules}>
|
||||
{moduleOptions.filter((m) => m.isSubscribed).length > 0 ? (
|
||||
<>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
选择用户可以访问的模块。不选择则默认继承租户所有模块权限。
|
||||
</Text>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
{moduleOptions.filter((m) => m.isSubscribed).map((module) => (
|
||||
<Col span={12} key={module.code}>
|
||||
<Checkbox value={module.code}>{module.name}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Checkbox.Group>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary">该租户暂无可用模块</Text>
|
||||
)}
|
||||
</Spin>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignTenantModal;
|
||||
|
||||
301
frontend-v2/src/modules/admin/components/ImportUserModal.tsx
Normal file
301
frontend-v2/src/modules/admin/components/ImportUserModal.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 批量导入用户弹窗
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Upload,
|
||||
Button,
|
||||
Select,
|
||||
Alert,
|
||||
Table,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as userApi from '../api/userApi';
|
||||
import type { TenantOption, ImportUserRow, ImportResult } from '../types/user';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ImportUserModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
tenantOptions: TenantOption[];
|
||||
}
|
||||
|
||||
const ImportUserModal: React.FC<ImportUserModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSuccess,
|
||||
tenantOptions,
|
||||
}) => {
|
||||
const [step, setStep] = useState<'upload' | 'preview' | 'result'>('upload');
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [parsedData, setParsedData] = useState<ImportUserRow[]>([]);
|
||||
const [defaultTenantId, setDefaultTenantId] = useState<string>();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
|
||||
// 重置状态
|
||||
const resetState = () => {
|
||||
setStep('upload');
|
||||
setFileList([]);
|
||||
setParsedData([]);
|
||||
setDefaultTenantId(undefined);
|
||||
setImportResult(null);
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 解析Excel文件
|
||||
const parseExcel = (file: File): Promise<ImportUserRow[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
const workbook = XLSX.read(data, { type: 'binary' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json<any>(worksheet);
|
||||
|
||||
// 映射列名
|
||||
const rows: ImportUserRow[] = jsonData.map((row) => ({
|
||||
phone: String(row['手机号'] || row['phone'] || '').trim(),
|
||||
name: String(row['姓名'] || row['name'] || '').trim(),
|
||||
email: row['邮箱'] || row['email'] || undefined,
|
||||
role: row['角色'] || row['role'] || undefined,
|
||||
tenantCode: row['租户代码'] || row['tenantCode'] || undefined,
|
||||
departmentName: row['科室'] || row['departmentName'] || undefined,
|
||||
modules: row['模块'] || row['modules'] || undefined,
|
||||
}));
|
||||
|
||||
resolve(rows);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 处理文件上传
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const rows = await parseExcel(file);
|
||||
if (rows.length === 0) {
|
||||
message.error('文件中没有数据');
|
||||
return false;
|
||||
}
|
||||
setParsedData(rows);
|
||||
setStep('preview');
|
||||
} catch (error) {
|
||||
message.error('解析文件失败');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 执行导入
|
||||
const handleImport = async () => {
|
||||
if (!defaultTenantId) {
|
||||
message.warning('请选择默认租户');
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await userApi.importUsers(parsedData, defaultTenantId);
|
||||
setImportResult(result);
|
||||
setStep('result');
|
||||
if (result.success > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载模板
|
||||
const downloadTemplate = () => {
|
||||
const template = [
|
||||
{
|
||||
'手机号': '13800138000',
|
||||
'姓名': '张三',
|
||||
'邮箱': 'zhangsan@example.com',
|
||||
'角色': 'USER',
|
||||
'租户代码': '',
|
||||
'科室': '',
|
||||
'模块': 'AIA,PKB',
|
||||
},
|
||||
];
|
||||
const ws = XLSX.utils.json_to_sheet(template);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '用户导入模板');
|
||||
XLSX.writeFile(wb, '用户导入模板.xlsx');
|
||||
};
|
||||
|
||||
// 预览表格列
|
||||
const previewColumns = [
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 100 },
|
||||
{ title: '租户代码', dataIndex: 'tenantCode', key: 'tenantCode', width: 100 },
|
||||
{ title: '科室', dataIndex: 'departmentName', key: 'departmentName', width: 100 },
|
||||
{ title: '模块', dataIndex: 'modules', key: 'modules', width: 120 },
|
||||
];
|
||||
|
||||
// 错误表格列
|
||||
const errorColumns = [
|
||||
{ title: '行号', dataIndex: 'row', key: 'row', width: 80 },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
|
||||
{ title: '错误原因', dataIndex: 'error', key: 'error' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="批量导入用户"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
width={800}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
{step === 'upload' && (
|
||||
<>
|
||||
<Alert
|
||||
message="导入说明"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>支持 .xlsx 或 .xls 格式的Excel文件</li>
|
||||
<li>必填字段:手机号、姓名</li>
|
||||
<li>角色可选值:SUPER_ADMIN、HOSPITAL_ADMIN、PHARMA_ADMIN、DEPARTMENT_ADMIN、USER</li>
|
||||
<li>模块字段使用逗号分隔,如:AIA,PKB,RVW</li>
|
||||
</ul>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>
|
||||
下载导入模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dragger
|
||||
accept=".xlsx,.xls"
|
||||
fileList={fileList}
|
||||
beforeUpload={handleUpload}
|
||||
onChange={({ fileList }) => setFileList(fileList)}
|
||||
maxCount={1}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">支持 .xlsx 或 .xls 格式</p>
|
||||
</Dragger>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'preview' && (
|
||||
<>
|
||||
<Alert
|
||||
message={`共解析 ${parsedData.length} 条数据,请确认后导入`}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Text>默认租户:</Text>
|
||||
<Select
|
||||
placeholder="请选择默认租户"
|
||||
value={defaultTenantId}
|
||||
onChange={setDefaultTenantId}
|
||||
style={{ width: 250 }}
|
||||
options={tenantOptions.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={previewColumns}
|
||||
dataSource={parsedData.map((row, i) => ({ ...row, key: i }))}
|
||||
size="small"
|
||||
scroll={{ x: 800, y: 300 }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setStep('upload')}>上一步</Button>
|
||||
<Button type="primary" loading={importing} onClick={handleImport}>
|
||||
开始导入
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'result' && importResult && (
|
||||
<>
|
||||
<Alert
|
||||
message={`导入完成:成功 ${importResult.success} 条,失败 ${importResult.failed} 条`}
|
||||
type={importResult.failed === 0 ? 'success' : 'warning'}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
失败记录:
|
||||
</Text>
|
||||
<Table
|
||||
columns={errorColumns}
|
||||
dataSource={importResult.errors.map((e, i) => ({ ...e, key: i }))}
|
||||
size="small"
|
||||
scroll={{ y: 200 }}
|
||||
pagination={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={resetState}>继续导入</Button>
|
||||
<Button type="primary" onClick={handleClose}>
|
||||
完成
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportUserModal;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 模块权限配置弹窗
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Row,
|
||||
Col,
|
||||
message,
|
||||
Typography,
|
||||
Spin,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import * as userApi from '../api/userApi';
|
||||
import type { TenantMembership, ModuleOption } from '../types/user';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ModulePermissionModalProps {
|
||||
visible: boolean;
|
||||
userId: string;
|
||||
membership: TenantMembership;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ModulePermissionModal: React.FC<ModulePermissionModalProps> = ({
|
||||
visible,
|
||||
userId,
|
||||
membership,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [moduleOptions, setModuleOptions] = useState<ModuleOption[]>([]);
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
|
||||
// 加载模块选项
|
||||
useEffect(() => {
|
||||
if (visible && membership) {
|
||||
setLoading(true);
|
||||
userApi.getModuleOptions(membership.tenantId)
|
||||
.then((options) => {
|
||||
setModuleOptions(options);
|
||||
// 设置当前已启用的模块
|
||||
const enabled = membership.allowedModules
|
||||
.filter((m) => m.isEnabled)
|
||||
.map((m) => m.code);
|
||||
setSelectedModules(enabled);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [visible, membership]);
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.updateUserModules(userId, {
|
||||
tenantId: membership.tenantId,
|
||||
modules: selectedModules,
|
||||
});
|
||||
message.success('模块权限更新成功');
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '更新失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 可用模块(租户已订阅的)
|
||||
const subscribedModules = moduleOptions.filter((m) => m.isSubscribed);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`配置模块权限 - ${membership.tenantName}`}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Alert
|
||||
message="模块权限说明"
|
||||
description="选择用户在该租户内可以访问的模块。取消所有选择将默认继承租户的全部模块权限。"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{subscribedModules.length > 0 ? (
|
||||
<Checkbox.Group
|
||||
value={selectedModules}
|
||||
onChange={(values) => setSelectedModules(values as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{subscribedModules.map((module) => (
|
||||
<Col span={12} key={module.code}>
|
||||
<Checkbox value={module.code}>{module.name}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Checkbox.Group>
|
||||
) : (
|
||||
<Text type="secondary">该租户暂无可用模块</Text>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModulePermissionModal;
|
||||
|
||||
31
frontend-v2/src/modules/admin/index.tsx
Normal file
31
frontend-v2/src/modules/admin/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* ADMIN(运营管理端)模块入口
|
||||
*
|
||||
* 功能:
|
||||
* - 用户管理
|
||||
* - 租户管理(已有)
|
||||
* - Prompt管理(已有)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import UserListPage from './pages/UserListPage';
|
||||
import UserFormPage from './pages/UserFormPage';
|
||||
import UserDetailPage from './pages/UserDetailPage';
|
||||
|
||||
const AdminModule: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="users" replace />} />
|
||||
|
||||
{/* 用户管理 */}
|
||||
<Route path="users" element={<UserListPage />} />
|
||||
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
||||
<Route path="users/:id" element={<UserDetailPage />} />
|
||||
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminModule;
|
||||
|
||||
392
frontend-v2/src/modules/admin/pages/UserDetailPage.tsx
Normal file
392
frontend-v2/src/modules/admin/pages/UserDetailPage.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 用户详情页
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Descriptions,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Spin,
|
||||
message,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Table,
|
||||
Modal,
|
||||
Tooltip,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
EditOutlined,
|
||||
KeyOutlined,
|
||||
StopOutlined,
|
||||
CheckCircleOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SettingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import * as userApi from '../api/userApi';
|
||||
import type { UserDetail, TenantMembership, TenantOption, UserRole } from '../types/user';
|
||||
import { ROLE_DISPLAY_NAMES, ROLE_COLORS, TENANT_TYPE_NAMES } from '../types/user';
|
||||
import AssignTenantModal from '../components/AssignTenantModal';
|
||||
import ModulePermissionModal from '../components/ModulePermissionModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const UserDetailPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<UserDetail | null>(null);
|
||||
const [tenantOptions, setTenantOptions] = useState<TenantOption[]>([]);
|
||||
const [assignTenantVisible, setAssignTenantVisible] = useState(false);
|
||||
const [modulePermissionVisible, setModulePermissionVisible] = useState(false);
|
||||
const [selectedMembership, setSelectedMembership] = useState<TenantMembership | null>(null);
|
||||
|
||||
// 加载用户详情
|
||||
const loadUser = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await userApi.getUserById(id);
|
||||
setUser(data);
|
||||
} catch (error) {
|
||||
console.error('加载用户详情失败:', error);
|
||||
message.error('加载用户详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
userApi.getTenantOptions().then(setTenantOptions).catch(console.error);
|
||||
}, [id]);
|
||||
|
||||
// 处理状态切换
|
||||
const handleToggleStatus = () => {
|
||||
if (!user) return;
|
||||
const newStatus = user.status === 'active' ? 'disabled' : 'active';
|
||||
const actionText = newStatus === 'active' ? '启用' : '禁用';
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认${actionText}`,
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确定要${actionText}用户 "${user.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userApi.updateUserStatus(user.id, newStatus);
|
||||
message.success(`${actionText}成功`);
|
||||
loadUser();
|
||||
} catch (error) {
|
||||
message.error(`${actionText}失败`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理重置密码
|
||||
const handleResetPassword = () => {
|
||||
if (!user) return;
|
||||
Modal.confirm({
|
||||
title: '确认重置密码',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<p>确定要重置用户 "{user.name}" 的密码吗?</p>
|
||||
<p><Text type="warning">重置后密码将变为默认密码:123456</Text></p>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userApi.resetUserPassword(user.id);
|
||||
message.success('密码已重置');
|
||||
loadUser();
|
||||
} catch (error) {
|
||||
message.error('重置密码失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理移除租户
|
||||
const handleRemoveTenant = (membership: TenantMembership) => {
|
||||
if (!user) return;
|
||||
|
||||
if (membership.tenantId === user.defaultTenant.id) {
|
||||
message.warning('不能移除用户的默认租户');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认移除租户',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确定要从租户 "${membership.tenantName}" 移除该用户吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userApi.removeTenantFromUser(user.id, membership.tenantId);
|
||||
message.success('移除成功');
|
||||
loadUser();
|
||||
} catch (error) {
|
||||
message.error('移除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 打开模块权限配置
|
||||
const handleConfigModules = (membership: TenantMembership) => {
|
||||
setSelectedMembership(membership);
|
||||
setModulePermissionVisible(true);
|
||||
};
|
||||
|
||||
// 租户成员表格列
|
||||
const tenantColumns: ColumnsType<TenantMembership> = [
|
||||
{
|
||||
title: '租户',
|
||||
key: 'tenant',
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space>
|
||||
<Text strong>{record.tenantName}</Text>
|
||||
{user?.defaultTenant.id === record.tenantId && (
|
||||
<Tag color="blue">默认</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{TENANT_TYPE_NAMES[record.tenantType]} · {record.tenantCode}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 120,
|
||||
render: (role: UserRole) => (
|
||||
<Tag color={ROLE_COLORS[role]}>{ROLE_DISPLAY_NAMES[role]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '模块权限',
|
||||
key: 'modules',
|
||||
render: (_, record) => {
|
||||
const enabledModules = record.allowedModules.filter((m) => m.isEnabled);
|
||||
if (enabledModules.length === 0) {
|
||||
return <Text type="secondary">无模块权限</Text>;
|
||||
}
|
||||
return (
|
||||
<Space wrap size={4}>
|
||||
{enabledModules.slice(0, 4).map((m) => (
|
||||
<Tag key={m.code}>{m.name}</Tag>
|
||||
))}
|
||||
{enabledModules.length > 4 && (
|
||||
<Tooltip title={enabledModules.slice(4).map((m) => m.name).join('、')}>
|
||||
<Tag>+{enabledModules.length - 4}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '加入时间',
|
||||
dataIndex: 'joinedAt',
|
||||
key: 'joinedAt',
|
||||
width: 160,
|
||||
render: (date) => new Date(date).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Tooltip title="配置模块权限">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => handleConfigModules(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{user?.defaultTenant.id !== record.tenantId && (
|
||||
<Tooltip title="移除租户">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveTenant(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty description="用户不存在" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 基本信息卡片 */}
|
||||
<Card>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/admin/users')}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={4} style={{ margin: 0 }}>用户详情</Title>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/admin/users/${id}/edit`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
icon={<KeyOutlined />}
|
||||
onClick={handleResetPassword}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button
|
||||
icon={user.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />}
|
||||
danger={user.status === 'active'}
|
||||
onClick={handleToggleStatus}
|
||||
>
|
||||
{user.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Descriptions column={3} bordered>
|
||||
<Descriptions.Item label="姓名">
|
||||
<Text strong>{user.name}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{user.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{user.email || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="系统角色">
|
||||
<Tag color={ROLE_COLORS[user.role]}>{ROLE_DISPLAY_NAMES[user.role]}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={user.status === 'active' ? 'success' : 'default'}>
|
||||
{user.status === 'active' ? '正常' : '已禁用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="密码状态">
|
||||
{user.isDefaultPassword ? (
|
||||
<Tag color="warning">默认密码</Tag>
|
||||
) : (
|
||||
<Tag color="success">已修改</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="默认租户">
|
||||
{user.defaultTenant.name} ({user.defaultTenant.code})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属科室">
|
||||
{user.department?.name || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{new Date(user.createdAt).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后登录" span={3}>
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '从未登录'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 租户关系卡片 */}
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Title level={5} style={{ margin: 0 }}>租户关系</Title>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setAssignTenantVisible(true)}
|
||||
>
|
||||
分配租户
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={tenantColumns}
|
||||
dataSource={user.tenantMemberships}
|
||||
rowKey="tenantId"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 分配租户弹窗 */}
|
||||
<AssignTenantModal
|
||||
visible={assignTenantVisible}
|
||||
userId={user.id}
|
||||
existingTenantIds={user.tenantMemberships.map((m) => m.tenantId)}
|
||||
tenantOptions={tenantOptions}
|
||||
onClose={() => setAssignTenantVisible(false)}
|
||||
onSuccess={() => {
|
||||
setAssignTenantVisible(false);
|
||||
loadUser();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 模块权限配置弹窗 */}
|
||||
{selectedMembership && (
|
||||
<ModulePermissionModal
|
||||
visible={modulePermissionVisible}
|
||||
userId={user.id}
|
||||
membership={selectedMembership}
|
||||
onClose={() => {
|
||||
setModulePermissionVisible(false);
|
||||
setSelectedMembership(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setModulePermissionVisible(false);
|
||||
setSelectedMembership(null);
|
||||
loadUser();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetailPage;
|
||||
|
||||
339
frontend-v2/src/modules/admin/pages/UserFormPage.tsx
Normal file
339
frontend-v2/src/modules/admin/pages/UserFormPage.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 用户创建/编辑页
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Spin,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
Checkbox,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import * as userApi from '../api/userApi';
|
||||
import type {
|
||||
TenantOption,
|
||||
DepartmentOption,
|
||||
ModuleOption,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
} from '../types/user';
|
||||
import { ROLE_DISPLAY_NAMES } from '../types/user';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface UserFormPageProps {
|
||||
mode: 'create' | 'edit';
|
||||
}
|
||||
|
||||
const UserFormPage: React.FC<UserFormPageProps> = ({ mode }) => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tenantOptions, setTenantOptions] = useState<TenantOption[]>([]);
|
||||
const [departmentOptions, setDepartmentOptions] = useState<DepartmentOption[]>([]);
|
||||
const [moduleOptions, setModuleOptions] = useState<ModuleOption[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>();
|
||||
|
||||
// 加载租户选项
|
||||
useEffect(() => {
|
||||
userApi.getTenantOptions().then(setTenantOptions).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// 当选择租户时加载科室和模块选项
|
||||
useEffect(() => {
|
||||
if (selectedTenantId) {
|
||||
Promise.all([
|
||||
userApi.getDepartmentOptions(selectedTenantId),
|
||||
userApi.getModuleOptions(selectedTenantId),
|
||||
]).then(([depts, mods]) => {
|
||||
setDepartmentOptions(depts);
|
||||
setModuleOptions(mods);
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
setDepartmentOptions([]);
|
||||
setModuleOptions([]);
|
||||
}
|
||||
}, [selectedTenantId]);
|
||||
|
||||
// 编辑模式下加载用户数据
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && id) {
|
||||
setLoading(true);
|
||||
userApi.getUserById(id)
|
||||
.then((user) => {
|
||||
form.setFieldsValue({
|
||||
phone: user.phone,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.defaultTenant.id,
|
||||
departmentId: user.department?.id,
|
||||
});
|
||||
setSelectedTenantId(user.defaultTenant.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('加载用户数据失败:', error);
|
||||
message.error('加载用户数据失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [mode, id, form]);
|
||||
|
||||
// 处理租户变更
|
||||
const handleTenantChange = (tenantId: string) => {
|
||||
setSelectedTenantId(tenantId);
|
||||
form.setFieldsValue({ departmentId: undefined, allowedModules: [] });
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values: any) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const data: CreateUserRequest = {
|
||||
phone: values.phone,
|
||||
name: values.name,
|
||||
email: values.email || undefined,
|
||||
role: values.role,
|
||||
tenantId: values.tenantId,
|
||||
departmentId: values.departmentId || undefined,
|
||||
tenantRole: values.tenantRole || values.role,
|
||||
allowedModules: values.allowedModules?.length > 0 ? values.allowedModules : undefined,
|
||||
};
|
||||
await userApi.createUser(data);
|
||||
message.success('用户创建成功');
|
||||
} else {
|
||||
const data: UpdateUserRequest = {
|
||||
name: values.name,
|
||||
email: values.email || undefined,
|
||||
role: values.role,
|
||||
departmentId: values.departmentId || undefined,
|
||||
};
|
||||
await userApi.updateUser(id!, data);
|
||||
message.success('用户更新成功');
|
||||
}
|
||||
navigate('/admin/users');
|
||||
} catch (error: any) {
|
||||
console.error('保存用户失败:', error);
|
||||
message.error(error.response?.data?.message || '保存用户失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 可用角色选项
|
||||
const roleOptions = Object.entries(ROLE_DISPLAY_NAMES).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Spin spinning={loading}>
|
||||
<Card>
|
||||
{/* 页面头部 */}
|
||||
<Row align="middle" style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/admin/users')}
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{mode === 'create' ? '创建用户' : '编辑用户'}
|
||||
</Title>
|
||||
</Row>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{
|
||||
role: 'USER',
|
||||
}}
|
||||
style={{ maxWidth: 800 }}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Title level={5}>基本信息</Title>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入手机号"
|
||||
maxLength={11}
|
||||
disabled={mode === 'edit'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入姓名' }]}
|
||||
>
|
||||
<Input placeholder="请输入姓名" maxLength={50} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[{ type: 'email', message: '请输入正确的邮箱' }]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱(可选)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="系统角色"
|
||||
name="role"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select placeholder="请选择角色" options={roleOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{mode === 'create' && (
|
||||
<Alert
|
||||
message="默认密码"
|
||||
description="新用户的初始密码为 123456,用户首次登录后需要修改密码。"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 租户配置 */}
|
||||
<Title level={5}>租户配置</Title>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="默认租户"
|
||||
name="tenantId"
|
||||
rules={[{ required: true, message: '请选择租户' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择租户"
|
||||
options={tenantOptions.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.code})`,
|
||||
}))}
|
||||
onChange={handleTenantChange}
|
||||
disabled={mode === 'edit'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="所属科室" name="departmentId">
|
||||
<Select
|
||||
placeholder="请选择科室(可选)"
|
||||
allowClear
|
||||
disabled={!selectedTenantId || departmentOptions.length === 0}
|
||||
options={departmentOptions.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{mode === 'create' && (
|
||||
<>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="在该租户内的角色"
|
||||
name="tenantRole"
|
||||
tooltip="默认与系统角色相同"
|
||||
>
|
||||
<Select
|
||||
placeholder="默认与系统角色相同"
|
||||
allowClear
|
||||
options={roleOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 模块权限配置 */}
|
||||
{moduleOptions.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Title level={5}>模块权限</Title>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
选择用户可以访问的模块。不选择则默认继承租户所有已订阅模块权限。
|
||||
</Text>
|
||||
<Form.Item name="allowedModules">
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<Row gutter={[16, 8]}>
|
||||
{moduleOptions.filter((m) => m.isSubscribed).map((module) => (
|
||||
<Col span={8} key={module.code}>
|
||||
<Checkbox value={module.code}>{module.name}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
>
|
||||
{mode === 'create' ? '创建用户' : '保存修改'}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/admin/users')}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFormPage;
|
||||
|
||||
410
frontend-v2/src/modules/admin/pages/UserListPage.tsx
Normal file
410
frontend-v2/src/modules/admin/pages/UserListPage.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 用户管理列表页
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Dropdown,
|
||||
Modal,
|
||||
message,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
MoreOutlined,
|
||||
UserOutlined,
|
||||
EditOutlined,
|
||||
StopOutlined,
|
||||
CheckCircleOutlined,
|
||||
KeyOutlined,
|
||||
ImportOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { MenuProps } from 'antd';
|
||||
import * as userApi from '../api/userApi';
|
||||
import type {
|
||||
UserListItem,
|
||||
ListUsersParams,
|
||||
UserRole,
|
||||
UserStatus,
|
||||
TenantOption,
|
||||
} from '../types/user';
|
||||
import { ROLE_DISPLAY_NAMES, ROLE_COLORS, TENANT_TYPE_NAMES } from '../types/user';
|
||||
import ImportUserModal from '../components/ImportUserModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const UserListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<UserListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | undefined>();
|
||||
const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();
|
||||
const [tenantFilter, setTenantFilter] = useState<string | undefined>();
|
||||
const [tenantOptions, setTenantOptions] = useState<TenantOption[]>([]);
|
||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||
|
||||
// 加载租户选项
|
||||
useEffect(() => {
|
||||
userApi.getTenantOptions().then(setTenantOptions).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: ListUsersParams = {
|
||||
page,
|
||||
pageSize,
|
||||
search: search || undefined,
|
||||
role: roleFilter,
|
||||
status: statusFilter,
|
||||
tenantId: tenantFilter,
|
||||
};
|
||||
const result = await userApi.listUsers(params);
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error);
|
||||
message.error('加载用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, search, roleFilter, statusFilter, tenantFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
// 处理状态切换
|
||||
const handleToggleStatus = async (user: UserListItem) => {
|
||||
const newStatus = user.status === 'active' ? 'disabled' : 'active';
|
||||
const actionText = newStatus === 'active' ? '启用' : '禁用';
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认${actionText}`,
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `确定要${actionText}用户 "${user.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userApi.updateUserStatus(user.id, newStatus);
|
||||
message.success(`${actionText}成功`);
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
message.error(`${actionText}失败`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理重置密码
|
||||
const handleResetPassword = (user: UserListItem) => {
|
||||
Modal.confirm({
|
||||
title: '确认重置密码',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<p>确定要重置用户 "{user.name}" 的密码吗?</p>
|
||||
<p><Text type="warning">重置后密码将变为默认密码:123456</Text></p>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userApi.resetUserPassword(user.id);
|
||||
message.success('密码已重置');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
message.error('重置密码失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 操作菜单
|
||||
const getActionMenu = (user: UserListItem): MenuProps => ({
|
||||
items: [
|
||||
{
|
||||
key: 'view',
|
||||
icon: <UserOutlined />,
|
||||
label: '查看详情',
|
||||
onClick: () => navigate(`/admin/users/${user.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: '编辑用户',
|
||||
onClick: () => navigate(`/admin/users/${user.id}/edit`),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'toggle-status',
|
||||
icon: user.status === 'active' ? <StopOutlined /> : <CheckCircleOutlined />,
|
||||
label: user.status === 'active' ? '禁用用户' : '启用用户',
|
||||
onClick: () => handleToggleStatus(user),
|
||||
},
|
||||
{
|
||||
key: 'reset-password',
|
||||
icon: <KeyOutlined />,
|
||||
label: '重置密码',
|
||||
onClick: () => handleResetPassword(user),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<UserListItem> = [
|
||||
{
|
||||
title: '用户信息',
|
||||
key: 'userInfo',
|
||||
width: 280,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space>
|
||||
<Text strong>{record.name}</Text>
|
||||
{record.isDefaultPassword && (
|
||||
<Tooltip title="使用默认密码,建议修改">
|
||||
<Tag color="warning" style={{ fontSize: 10 }}>默认密码</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{record.phone}</Text>
|
||||
{record.email && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{record.email}</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 120,
|
||||
render: (role: UserRole) => (
|
||||
<Tag color={ROLE_COLORS[role]}>{ROLE_DISPLAY_NAMES[role]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '默认租户',
|
||||
key: 'tenant',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{record.defaultTenant.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{TENANT_TYPE_NAMES[record.defaultTenant.type]} · {record.defaultTenant.code}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '科室',
|
||||
key: 'department',
|
||||
width: 120,
|
||||
render: (_, record) => record.department?.name || '-',
|
||||
},
|
||||
{
|
||||
title: '租户数',
|
||||
dataIndex: 'tenantCount',
|
||||
key: 'tenantCount',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (count: number) => (
|
||||
<Badge count={count} showZero style={{ backgroundColor: count > 1 ? '#1890ff' : '#d9d9d9' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (status: UserStatus) => (
|
||||
<Tag color={status === 'active' ? 'success' : 'default'}>
|
||||
{status === 'active' ? '正常' : '已禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'lastLoginAt',
|
||||
key: 'lastLoginAt',
|
||||
width: 160,
|
||||
render: (lastLoginAt: string | null) =>
|
||||
lastLoginAt ? new Date(lastLoginAt).toLocaleString('zh-CN') : '从未登录',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Dropdown menu={getActionMenu(record)} trigger={['click']}>
|
||||
<Button type="text" icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
{/* 页面头部 */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
|
||||
<Col>
|
||||
<Title level={4} style={{ margin: 0 }}>用户管理</Title>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ImportOutlined />}
|
||||
onClick={() => setImportModalVisible(true)}
|
||||
>
|
||||
批量导入
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/admin/users/create')}
|
||||
>
|
||||
创建用户
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col flex="300px">
|
||||
<Input
|
||||
placeholder="搜索手机号、姓名、邮箱"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="150px">
|
||||
<Select
|
||||
placeholder="角色"
|
||||
value={roleFilter}
|
||||
onChange={setRoleFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={Object.entries(ROLE_DISPLAY_NAMES).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="150px">
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'active', label: '正常' },
|
||||
{ value: 'disabled', label: '已禁用' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="200px">
|
||||
<Select
|
||||
placeholder="所属租户"
|
||||
value={tenantFilter}
|
||||
onChange={setTenantFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={tenantOptions.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setRoleFilter(undefined);
|
||||
setStatusFilter(undefined);
|
||||
setTenantFilter(undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 批量导入弹窗 */}
|
||||
<ImportUserModal
|
||||
visible={importModalVisible}
|
||||
onClose={() => setImportModalVisible(false)}
|
||||
onSuccess={() => {
|
||||
setImportModalVisible(false);
|
||||
loadUsers();
|
||||
}}
|
||||
tenantOptions={tenantOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserListPage;
|
||||
|
||||
196
frontend-v2/src/modules/admin/types/user.ts
Normal file
196
frontend-v2/src/modules/admin/types/user.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 用户管理类型定义
|
||||
*/
|
||||
|
||||
// 用户角色
|
||||
export type UserRole =
|
||||
| 'SUPER_ADMIN'
|
||||
| 'PROMPT_ENGINEER'
|
||||
| 'HOSPITAL_ADMIN'
|
||||
| 'PHARMA_ADMIN'
|
||||
| 'DEPARTMENT_ADMIN'
|
||||
| 'USER';
|
||||
|
||||
// 租户类型
|
||||
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
|
||||
|
||||
// 用户状态
|
||||
export type UserStatus = 'active' | 'disabled';
|
||||
|
||||
// 用户列表项
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
isDefaultPassword: boolean;
|
||||
defaultTenant: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
};
|
||||
department: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
tenantCount: number;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
// 租户成员关系
|
||||
export interface TenantMembership {
|
||||
tenantId: string;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
tenantType: TenantType;
|
||||
role: UserRole;
|
||||
joinedAt: string;
|
||||
allowedModules: ModulePermission[];
|
||||
}
|
||||
|
||||
// 模块权限
|
||||
export interface ModulePermission {
|
||||
code: string;
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// 用户详情
|
||||
export interface UserDetail extends UserListItem {
|
||||
tenantMemberships: TenantMembership[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
// 列表查询参数
|
||||
export interface ListUsersParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
role?: UserRole;
|
||||
tenantId?: string;
|
||||
status?: UserStatus;
|
||||
departmentId?: string;
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 创建用户请求
|
||||
export interface CreateUserRequest {
|
||||
phone: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
role: UserRole;
|
||||
tenantId: string;
|
||||
departmentId?: string;
|
||||
tenantRole?: UserRole;
|
||||
allowedModules?: string[];
|
||||
}
|
||||
|
||||
// 更新用户请求
|
||||
export interface UpdateUserRequest {
|
||||
name?: string;
|
||||
email?: string;
|
||||
role?: UserRole;
|
||||
departmentId?: string;
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
// 分配租户请求
|
||||
export interface AssignTenantRequest {
|
||||
tenantId: string;
|
||||
role: UserRole;
|
||||
allowedModules?: string[];
|
||||
}
|
||||
|
||||
// 更新模块权限请求
|
||||
export interface UpdateUserModulesRequest {
|
||||
tenantId: string;
|
||||
modules: string[];
|
||||
}
|
||||
|
||||
// 批量导入用户行
|
||||
export interface ImportUserRow {
|
||||
phone: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
tenantCode?: string;
|
||||
departmentName?: string;
|
||||
modules?: string;
|
||||
}
|
||||
|
||||
// 导入错误
|
||||
export interface ImportError {
|
||||
row: number;
|
||||
phone: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
// 导入结果
|
||||
export interface ImportResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: ImportError[];
|
||||
}
|
||||
|
||||
// 租户选项(下拉列表用)
|
||||
export interface TenantOption {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: TenantType;
|
||||
}
|
||||
|
||||
// 科室选项(下拉列表用)
|
||||
export interface DepartmentOption {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
// 模块选项(配置用)
|
||||
export interface ModuleOption {
|
||||
code: string;
|
||||
name: string;
|
||||
isSubscribed: boolean;
|
||||
}
|
||||
|
||||
// 角色显示名称
|
||||
export const ROLE_DISPLAY_NAMES: Record<UserRole, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
PROMPT_ENGINEER: 'Prompt工程师',
|
||||
HOSPITAL_ADMIN: '医院管理员',
|
||||
PHARMA_ADMIN: '药企管理员',
|
||||
DEPARTMENT_ADMIN: '科室主任',
|
||||
USER: '普通用户',
|
||||
};
|
||||
|
||||
// 角色颜色
|
||||
export const ROLE_COLORS: Record<UserRole, string> = {
|
||||
SUPER_ADMIN: 'red',
|
||||
PROMPT_ENGINEER: 'purple',
|
||||
HOSPITAL_ADMIN: 'blue',
|
||||
PHARMA_ADMIN: 'green',
|
||||
DEPARTMENT_ADMIN: 'orange',
|
||||
USER: 'default',
|
||||
};
|
||||
|
||||
// 租户类型显示名称
|
||||
export const TENANT_TYPE_NAMES: Record<TenantType, string> = {
|
||||
HOSPITAL: '医院',
|
||||
PHARMA: '药企',
|
||||
INTERNAL: '内部',
|
||||
PUBLIC: '公共',
|
||||
};
|
||||
|
||||
@@ -76,3 +76,5 @@ export const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick }) => {
|
||||
|
||||
export default AgentCard;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,3 +6,5 @@ export { AgentHub } from './AgentHub';
|
||||
export { AgentCard } from './AgentCard';
|
||||
export { ChatWorkspace } from './ChatWorkspace';
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -170,3 +170,5 @@ export const BRAND_COLORS = {
|
||||
yellow: '#CA8A04',
|
||||
} as const;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -208,3 +208,5 @@
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -59,3 +59,5 @@ export interface Message {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -564,6 +564,8 @@ export default FulltextDetailDrawer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,8 @@ export const useAssets = (activeTab: AssetTabType) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,8 @@ export const useRecentTasks = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -346,6 +346,8 @@ export default DropnaDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -431,6 +431,8 @@ export default MetricTimePanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -317,6 +317,8 @@ export default PivotPanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ export function useSessionStatus({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface DataStats {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export type AssetTabType = 'all' | 'processed' | 'raw';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -295,3 +295,5 @@ export default KnowledgePage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -50,3 +50,5 @@ export interface BatchTemplate {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -128,3 +128,5 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -48,3 +48,5 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -71,3 +71,5 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -61,3 +61,5 @@ export default function Header({ onUpload }: HeaderProps) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -115,3 +115,5 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,3 +43,5 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,3 +78,5 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -20,3 +20,5 @@ export { default as TaskDetail } from './TaskDetail';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -289,3 +289,5 @@ export default function Dashboard() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -238,3 +238,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - /t/{tenantCode}/login - 租户专属登录(机构用户)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { Form, Input, Button, Tabs, message, Card, Space, Typography, Alert, Modal } from 'antd';
|
||||
import { PhoneOutlined, LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
|
||||
@@ -82,16 +82,36 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// 智能跳转:根据用户角色判断目标页面
|
||||
const getRedirectPath = useCallback(() => {
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
const userRole = user?.role;
|
||||
|
||||
// 如果目标是运营管理端,检查权限
|
||||
if (from.startsWith('/admin')) {
|
||||
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole || '');
|
||||
return canAccessAdmin ? from : '/';
|
||||
}
|
||||
|
||||
// 如果目标是机构管理端,检查权限
|
||||
if (from.startsWith('/org')) {
|
||||
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole || '');
|
||||
return canAccessOrg ? from : '/';
|
||||
}
|
||||
|
||||
// 其他页面直接跳转
|
||||
return from;
|
||||
}, [location, user]);
|
||||
|
||||
// 登录成功后检查是否需要修改密码
|
||||
useEffect(() => {
|
||||
if (user && user.isDefaultPassword) {
|
||||
setShowPasswordModal(true);
|
||||
} else if (user) {
|
||||
// 登录成功,跳转
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
navigate(from, { replace: true });
|
||||
// 登录成功,智能跳转
|
||||
navigate(getRedirectPath(), { replace: true });
|
||||
}
|
||||
}, [user, navigate, location]);
|
||||
}, [user, navigate, getRedirectPath]);
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = async () => {
|
||||
@@ -134,9 +154,7 @@ export default function LoginPage() {
|
||||
await changePassword(values);
|
||||
message.success('密码修改成功');
|
||||
setShowPasswordModal(false);
|
||||
// 跳转到首页
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
navigate(from, { replace: true });
|
||||
navigate(getRedirectPath(), { replace: true });
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '修改密码失败');
|
||||
}
|
||||
@@ -145,8 +163,7 @@ export default function LoginPage() {
|
||||
// 跳过修改密码
|
||||
const handleSkipPassword = () => {
|
||||
setShowPasswordModal(false);
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
navigate(from, { replace: true });
|
||||
navigate(getRedirectPath(), { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -371,3 +388,5 @@ export default function LoginPage() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -339,3 +339,5 @@ export default TenantListPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -248,3 +248,5 @@ export async function fetchModuleList(): Promise<ModuleInfo[]> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -467,3 +467,5 @@ export const AIStreamChat: React.FC<AIStreamChatProps> = ({
|
||||
|
||||
export default AIStreamChat;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -167,3 +167,5 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||
|
||||
export default ConversationList;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,3 +19,5 @@ export type {
|
||||
ConversationGroup,
|
||||
} from './useConversations';
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -311,3 +311,5 @@ export function useAIStream(config: UseAIStreamConfig): UseAIStreamReturn {
|
||||
|
||||
export default useAIStream;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -240,3 +240,5 @@ export function useConversations(config: UseConversationsConfig = {}): UseConver
|
||||
|
||||
export default useConversations;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -275,3 +275,5 @@
|
||||
/* 可在此添加暗色模式样式 */
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -211,3 +211,5 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -148,3 +148,5 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ export { default as Placeholder } from './Placeholder';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2
frontend-v2/src/vite-env.d.ts
vendored
2
frontend-v2/src/vite-env.d.ts
vendored
@@ -40,6 +40,8 @@ interface ImportMeta {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user