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
302 lines
8.8 KiB
TypeScript
302 lines
8.8 KiB
TypeScript
/**
|
||
* 批量导入用户弹窗
|
||
*/
|
||
|
||
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;
|
||
|