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

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

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

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

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

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

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

View File

@@ -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;