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
393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
/**
|
||
* 用户详情页
|
||
*/
|
||
|
||
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;
|
||
|