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,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_ADMINHOSPITAL_ADMINPHARMA_ADMINDEPARTMENT_ADMINUSER</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;