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:
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;
|
||||
|
||||
Reference in New Issue
Block a user