- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows. - Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization. - Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration. - Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress. - Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
860 lines
37 KiB
TypeScript
860 lines
37 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import Sidebar from '../components/Sidebar';
|
||
import { UserPlus, Edit, Trash2, Upload, X } from 'lucide-react';
|
||
import { User, Template } from '../types';
|
||
import { storage } from '../utils/storage';
|
||
import {
|
||
createUser,
|
||
deleteUserFromApi,
|
||
listDepartments,
|
||
listUsers,
|
||
updateUser,
|
||
type Department,
|
||
} from '../api/users';
|
||
import { listTemplates } from '../api/templates';
|
||
import { deleteUserSignature, uploadUserSignature } from '../api/files';
|
||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||
|
||
const ADMIN_DISABLE_AUTH_KEY = 'DISABLE_ADMIN_2024';
|
||
|
||
export default function UserManage() {
|
||
const navigate = useNavigate();
|
||
const [users, setUsers] = useState<User[]>([]);
|
||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [formData, setFormData] = useState<Partial<User>>({
|
||
username: '',
|
||
name: '',
|
||
phone: '',
|
||
email: '',
|
||
password: '',
|
||
role: 'user',
|
||
department: '',
|
||
status: 'active',
|
||
visibleTemplates: [],
|
||
manageableTemplates: []
|
||
});
|
||
const [confirmPassword, setConfirmPassword] = useState('');
|
||
const [authKey, setAuthKey] = useState('');
|
||
const [allTemplates, setAllTemplates] = useState<Template[]>([]);
|
||
const [departments, setDepartments] = useState<Department[]>([]);
|
||
|
||
useEffect(() => {
|
||
const user = storage.get<User | null>('currentUser', null);
|
||
if (!user || (user.role !== 'super' && user.role !== 'admin')) {
|
||
navigate('/dashboard');
|
||
return;
|
||
}
|
||
setCurrentUser(user);
|
||
|
||
const savedUsers = storage.get<User[]>('users', []).filter(Boolean);
|
||
setUsers(savedUsers);
|
||
|
||
const savedTemplates = storage.get<Template[]>('templates', []).filter(Boolean);
|
||
setAllTemplates(savedTemplates);
|
||
|
||
Promise.allSettled([
|
||
listUsers(),
|
||
listDepartments(),
|
||
listTemplates('use'),
|
||
]).then(([usersResult, departmentsResult, templatesResult]) => {
|
||
if (usersResult.status === 'fulfilled') {
|
||
const apiUsers = mergeUsersWithLocalCache(usersResult.value.items, storage.get<User[]>('users', []));
|
||
setUsers(apiUsers);
|
||
storage.set('users', apiUsers);
|
||
}
|
||
if (departmentsResult.status === 'fulfilled') {
|
||
setDepartments(departmentsResult.value.items);
|
||
}
|
||
if (templatesResult.status === 'fulfilled') {
|
||
setAllTemplates(templatesResult.value.items);
|
||
storage.set('templates', templatesResult.value.items);
|
||
}
|
||
});
|
||
}, [navigate]);
|
||
|
||
const mergeUsersWithLocalCache = (apiUsers: User[], localUsers: User[]) =>
|
||
apiUsers.map((apiUser) => {
|
||
const local = localUsers.find((item) => item.username === apiUser.username);
|
||
return {
|
||
...local,
|
||
...apiUser,
|
||
signature: local?.signature ?? apiUser.signature,
|
||
password: local?.password,
|
||
};
|
||
});
|
||
|
||
const displayUsers = useMemo(() => {
|
||
if (!currentUser) return [];
|
||
const safeUsers = (Array.isArray(users) ? users : []).filter(Boolean);
|
||
if (currentUser.role === 'super') return safeUsers;
|
||
return safeUsers.filter(u => u.department === currentUser.department && (u.role === 'user' || u.username === currentUser.username));
|
||
}, [users, currentUser]);
|
||
|
||
const saveToLocalStorage = (updatedUsers: User[]) => {
|
||
setUsers(updatedUsers);
|
||
storage.set('users', updatedUsers);
|
||
};
|
||
|
||
const compressImage = (file: File, maxSize: number = 500): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.readAsDataURL(file);
|
||
reader.onload = (e) => {
|
||
const img = new Image();
|
||
img.src = e.target?.result as string;
|
||
img.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
let { width, height } = img;
|
||
if (width > height && width > maxSize) {
|
||
height = Math.round((height * maxSize) / width);
|
||
width = maxSize;
|
||
} else if (height > maxSize) {
|
||
width = Math.round((width * maxSize) / height);
|
||
height = maxSize;
|
||
}
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext('2d');
|
||
if (ctx) {
|
||
ctx.fillStyle = '#FFFFFF';
|
||
ctx.fillRect(0, 0, width, height);
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
}
|
||
resolve(canvas.toDataURL('image/jpeg', 0.8));
|
||
};
|
||
img.onerror = reject;
|
||
};
|
||
reader.onerror = reject;
|
||
});
|
||
};
|
||
|
||
const handleSignatureUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const compressed = await compressImage(file);
|
||
setFormData(prev => ({ ...prev, signature: compressed }));
|
||
} catch {
|
||
alert('图片压缩失败,请重试');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (user: User) => {
|
||
if (user.username === 'admin') {
|
||
alert('不能删除默认超级管理员');
|
||
return;
|
||
}
|
||
if (user.username === currentUser?.username) {
|
||
alert('不能删除当前登录账号');
|
||
return;
|
||
}
|
||
if (window.confirm(`确定要删除用户 "${user.username}" 吗?`)) {
|
||
try {
|
||
await deleteUserFromApi(user.id || user.username);
|
||
} catch (error) {
|
||
if (!isLocalFallbackEnabled()) {
|
||
alert(`删除失败: ${(error as Error).message}`);
|
||
return;
|
||
}
|
||
console.warn('Delete user API failed, keeping local compatibility path.', error);
|
||
}
|
||
const updated = users.filter(u => u.username !== user.username);
|
||
saveToLocalStorage(updated);
|
||
}
|
||
};
|
||
|
||
const handleEdit = (user: User) => {
|
||
if (currentUser?.role === 'admin') {
|
||
if ((user.role !== 'user' && user.username !== currentUser.username) || user.department !== currentUser.department) {
|
||
alert('您只能管理同部门的医生或您自己');
|
||
return;
|
||
}
|
||
}
|
||
setIsEditing(true);
|
||
const allTplIds = allTemplates.map(t => t.id);
|
||
let manageable: string[] = [];
|
||
let visible: string[] = [];
|
||
|
||
if (user.role === 'super') {
|
||
manageable = allTplIds;
|
||
visible = allTplIds;
|
||
} else if (user.role === 'admin') {
|
||
manageable = Array.isArray(user.manageableTemplates) ? user.manageableTemplates : allTplIds;
|
||
visible = (Array.isArray(user.visibleTemplates) ? user.visibleTemplates : manageable)
|
||
.filter(id => manageable.includes(id));
|
||
} else {
|
||
manageable = [];
|
||
const deptAdmin = users.find(u => u.role === 'admin' && u.department === user.department);
|
||
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||
visible = (Array.isArray(user.visibleTemplates) ? user.visibleTemplates : [])
|
||
.filter(id => adminManageable.includes(id));
|
||
}
|
||
|
||
setFormData({
|
||
...user,
|
||
password: '',
|
||
visibleTemplates: visible,
|
||
manageableTemplates: manageable
|
||
});
|
||
setConfirmPassword('');
|
||
setAuthKey('');
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleAdd = () => {
|
||
setIsEditing(false);
|
||
const defaultDept = currentUser?.role === 'admin' ? currentUser.department || '' : '';
|
||
const defaultRole = 'user';
|
||
let defaultVisible: string[] = [];
|
||
let defaultManageable: string[] = [];
|
||
|
||
if (currentUser?.role === 'admin') {
|
||
defaultManageable = [];
|
||
defaultVisible = currentUser.manageableTemplates || [];
|
||
}
|
||
|
||
setFormData({
|
||
username: '',
|
||
name: '',
|
||
phone: '',
|
||
email: '',
|
||
password: '',
|
||
role: defaultRole,
|
||
department: defaultDept,
|
||
status: 'active',
|
||
visibleTemplates: defaultVisible,
|
||
manageableTemplates: defaultManageable
|
||
});
|
||
setConfirmPassword('');
|
||
setAuthKey('');
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
try {
|
||
if (!formData.username) {
|
||
alert('用户ID不能为空');
|
||
return;
|
||
}
|
||
|
||
if (!isEditing && formData.role === 'super') {
|
||
alert('系统中只能存在一个超级管理员,无法新增');
|
||
return;
|
||
}
|
||
|
||
if (!isEditing && users.find(u => u.username === formData.username)) {
|
||
alert('用户ID已存在');
|
||
return;
|
||
}
|
||
|
||
if (isEditing && formData.password && formData.password !== confirmPassword) {
|
||
alert('两次输入的密码不一致');
|
||
return;
|
||
}
|
||
|
||
let finalRole = formData.role as any;
|
||
if (currentUser?.role === 'admin') {
|
||
if (!isEditing) finalRole = 'user';
|
||
else if (formData.username !== currentUser.username) finalRole = 'user';
|
||
}
|
||
const finalDepartment = currentUser?.role === 'admin' ? currentUser.department || '' : (formData.department || '');
|
||
|
||
if (finalRole === 'admin') {
|
||
const existingAdmin = users.find(u => u.role === 'admin' && u.department === finalDepartment && u.username !== formData.username);
|
||
if (existingAdmin) {
|
||
alert('该部门已存在管理员,一个部门只能有一个管理员');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (finalRole === 'user') {
|
||
const hasAdminInDept = users.some(u => u.role === 'admin' && u.department === finalDepartment);
|
||
if (!hasAdminInDept) {
|
||
alert('该部门暂无管理员,请先建立一个部门管理员再创建医生');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (finalRole !== 'user' && formData.status === 'inactive') {
|
||
if (authKey.trim() !== ADMIN_DISABLE_AUTH_KEY) {
|
||
alert('禁用管理员账号需要输入正确的授权密钥');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const allTplIds = allTemplates.map(t => t.id);
|
||
let manageableTemplates: string[] = [];
|
||
let visibleTemplates: string[] = [];
|
||
|
||
if (finalRole === 'super') {
|
||
manageableTemplates = allTplIds;
|
||
visibleTemplates = allTplIds;
|
||
} else if (finalRole === 'admin') {
|
||
manageableTemplates = (formData.manageableTemplates || []).filter(id => allTplIds.includes(id));
|
||
visibleTemplates = (formData.visibleTemplates || [])
|
||
.filter(id => manageableTemplates.includes(id));
|
||
} else if (finalRole === 'user') {
|
||
manageableTemplates = [];
|
||
const deptAdmin = users.find(u => u.role === 'admin' && u.department === finalDepartment);
|
||
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||
visibleTemplates = (formData.visibleTemplates || [])
|
||
.filter(id => adminManageable.includes(id));
|
||
}
|
||
|
||
const oldUser = isEditing ? users.find(u => u.username === formData.username) : undefined;
|
||
let updatedUsers: User[];
|
||
|
||
if (isEditing && finalRole === 'admin' && oldUser && oldUser.role === 'admin' && currentUser && currentUser.role === 'super') {
|
||
const oldManageable = Array.isArray(oldUser.manageableTemplates) ? oldUser.manageableTemplates : allTplIds;
|
||
const removed = oldManageable.filter(id => !manageableTemplates.includes(id));
|
||
const added = manageableTemplates.filter(id => !oldManageable.includes(id));
|
||
|
||
// Ensure admin's own visible gets new templates too
|
||
let adminVisible = [...visibleTemplates];
|
||
added.forEach(id => {
|
||
if (!adminVisible.includes(id)) adminVisible.push(id);
|
||
});
|
||
|
||
updatedUsers = users.map(u => {
|
||
if (u.username === formData.username) {
|
||
return { ...u, role: finalRole, department: finalDepartment, manageableTemplates, visibleTemplates: adminVisible, password: formData.password || u.password, signature: formData.signature } as User;
|
||
}
|
||
if (u.role === 'user' && u.department === (oldUser.department || finalDepartment)) {
|
||
const currentVisible = Array.isArray(u.visibleTemplates) ? u.visibleTemplates : [];
|
||
const nextVisible = currentVisible.filter(id => !removed.includes(id));
|
||
added.forEach(id => {
|
||
if (!nextVisible.includes(id)) nextVisible.push(id);
|
||
});
|
||
return { ...u, visibleTemplates: nextVisible };
|
||
}
|
||
return u;
|
||
});
|
||
} else {
|
||
const payload: Partial<User> = {
|
||
...formData,
|
||
role: finalRole,
|
||
department: finalDepartment,
|
||
manageableTemplates,
|
||
visibleTemplates
|
||
};
|
||
if (!formData.password) {
|
||
delete payload.password;
|
||
}
|
||
if (isEditing) {
|
||
updatedUsers = users.map(u => {
|
||
if (u.username === formData.username) {
|
||
return { ...u, ...payload } as User;
|
||
}
|
||
return u;
|
||
});
|
||
} else {
|
||
const newUser: User = {
|
||
...(payload as User),
|
||
createdAt: new Date().toISOString().split('T')[0]
|
||
};
|
||
updatedUsers = [...users, newUser];
|
||
}
|
||
}
|
||
|
||
let savedApiUser: User | null = null;
|
||
const payload = {
|
||
...(updatedUsers.find(u => u.username === formData.username) || formData),
|
||
departmentId: departments.find(dept => dept.name === finalDepartment)?.id || formData.departmentId,
|
||
} as User;
|
||
|
||
try {
|
||
savedApiUser = isEditing
|
||
? await updateUser(oldUser?.id || formData.id || formData.username || '', payload)
|
||
: await createUser(payload);
|
||
} catch (error: any) {
|
||
if (!isLocalFallbackEnabled()) {
|
||
alert(`后端保存失败:${error?.message || String(error)}`);
|
||
return;
|
||
}
|
||
console.warn('Save user API failed, keeping local compatibility path.', error);
|
||
if (error?.message) {
|
||
alert(`后端保存失败,已保留本地修改:${error.message}`);
|
||
}
|
||
}
|
||
|
||
if (savedApiUser) {
|
||
if (formData.signature?.startsWith('data:')) {
|
||
try {
|
||
const signatureFile = await uploadUserSignature(savedApiUser.id || savedApiUser.username, formData.signature);
|
||
savedApiUser = {
|
||
...savedApiUser,
|
||
signatureFileId: signatureFile.id,
|
||
signature: signatureFile.url,
|
||
};
|
||
} catch (error: any) {
|
||
if (!isLocalFallbackEnabled()) {
|
||
alert(`签名上传失败:${error?.message || String(error)}`);
|
||
return;
|
||
}
|
||
console.warn('Upload signature API failed, keeping local signature cache.', error);
|
||
alert(`签名上传到后端失败,已保留本地签名:${error?.message || String(error)}`);
|
||
}
|
||
} else if (oldUser?.signature && !formData.signature) {
|
||
try {
|
||
await deleteUserSignature(savedApiUser.id || savedApiUser.username);
|
||
savedApiUser = {
|
||
...savedApiUser,
|
||
signatureFileId: undefined,
|
||
signature: undefined,
|
||
};
|
||
} catch (error) {
|
||
if (!isLocalFallbackEnabled()) {
|
||
alert(`签名删除失败:${(error as Error).message}`);
|
||
return;
|
||
}
|
||
console.warn('Delete signature API failed, keeping local compatibility path.', error);
|
||
}
|
||
}
|
||
|
||
const localSignature = formData.signature;
|
||
const mergedSavedUser: User = {
|
||
...oldUser,
|
||
...savedApiUser,
|
||
signature: savedApiUser.signature ?? localSignature,
|
||
password: formData.password || oldUser?.password,
|
||
};
|
||
updatedUsers = isEditing
|
||
? users.map(u => u.username === mergedSavedUser.username ? mergedSavedUser : u)
|
||
: [...users, mergedSavedUser];
|
||
}
|
||
|
||
saveToLocalStorage(updatedUsers);
|
||
// 如果编辑的是当前登录用户,同步更新 currentUser
|
||
const currentCached = updatedUsers.find(u => u.username === currentUser?.username);
|
||
if (currentCached) {
|
||
storage.set('currentUser', currentCached);
|
||
setCurrentUser(currentCached);
|
||
}
|
||
setIsModalOpen(false);
|
||
} catch (err: any) {
|
||
alert('保存失败: ' + (err?.message || String(err)));
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
const toggleTemplate = (templateId: string, field: 'visibleTemplates' | 'manageableTemplates') => {
|
||
const current = (formData[field] || []) as string[];
|
||
if (current.includes(templateId)) {
|
||
setFormData({ ...formData, [field]: current.filter(id => id !== templateId) });
|
||
} else {
|
||
setFormData({ ...formData, [field]: [...current, templateId] });
|
||
}
|
||
};
|
||
|
||
// Real-time sync: when manageableTemplates changes for admin, ensure visibleTemplates stay within it
|
||
useEffect(() => {
|
||
if (!isModalOpen || !currentUser) return;
|
||
const isAdminEditingSelf = currentUser.role === 'admin' && formData.username === currentUser.username;
|
||
const isManageableReadonly = formData.role === 'super' || isAdminEditingSelf;
|
||
if (formData.role === 'admin' && !isManageableReadonly) {
|
||
const m = formData.manageableTemplates || [];
|
||
const prevVisible = formData.visibleTemplates || [];
|
||
const nextVisible = prevVisible.filter(id => m.includes(id));
|
||
if (nextVisible.length !== prevVisible.length) {
|
||
setFormData(prev => ({ ...prev, visibleTemplates: nextVisible }));
|
||
}
|
||
}
|
||
}, [formData.manageableTemplates, formData.role, isModalOpen, currentUser, formData.username]);
|
||
|
||
if (!currentUser) return null;
|
||
|
||
const roleNames = {
|
||
'super': '超级管理员',
|
||
'admin': '管理员',
|
||
'user': '医生'
|
||
};
|
||
|
||
const isAdminLogin = currentUser.role === 'admin';
|
||
const needAuthKey = formData.role !== 'user' && formData.status === 'inactive';
|
||
|
||
// 模板权限显示规则
|
||
const isSuperEditingAdmin = currentUser.role === 'super' && formData.role === 'admin';
|
||
const isSuperEditingUser = currentUser.role === 'super' && formData.role === 'user';
|
||
const isAdminEditingSelf = currentUser.role === 'admin' && formData.username === currentUser.username;
|
||
const isAdminEditingUser = currentUser.role === 'admin' && formData.role === 'user';
|
||
|
||
const showManageableTemplates = isSuperEditingAdmin || isAdminEditingSelf || formData.role === 'super';
|
||
const isManageableReadonly = formData.role === 'super' || isAdminEditingSelf;
|
||
const showVisibleTemplates = true;
|
||
|
||
// 可视模板候选
|
||
let visibleCandidates = allTemplates;
|
||
if (isSuperEditingAdmin) {
|
||
visibleCandidates = allTemplates.filter(t => (formData.manageableTemplates || []).includes(t.id));
|
||
} else if (isSuperEditingUser) {
|
||
const deptAdmin = users.find(u => u.role === 'admin' && u.department === formData.department);
|
||
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||
} else if (isAdminEditingSelf) {
|
||
const adminManageable = currentUser.manageableTemplates || [];
|
||
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||
} else if (isAdminEditingUser) {
|
||
const adminManageable = currentUser.manageableTemplates || [];
|
||
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-screen bg-bg">
|
||
<Sidebar />
|
||
|
||
<main className="flex-1 p-10 overflow-y-auto">
|
||
<header className="flex justify-between items-center mb-8">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight text-text-main">用户管理</h1>
|
||
<p className="text-text-muted text-sm mt-1">
|
||
{isAdminLogin ? '管理同部门的医生用户' : '管理系统用户,仅超级管理员可赋予管理员权限'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={handleAdd}
|
||
className="btn-accent inline-flex items-center gap-2"
|
||
>
|
||
<UserPlus size={18} />
|
||
新增用户
|
||
</button>
|
||
</header>
|
||
|
||
<div className="card-minimal p-0 overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full border-collapse">
|
||
<thead>
|
||
<tr className="bg-slate-50">
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">用户ID</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">姓名</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">邮箱</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">联系电话</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">角色</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">部门</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">签名状态</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">状态</th>
|
||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{displayUsers.map((user) => (
|
||
<tr key={user.username} className="hover:bg-slate-50 transition-colors group">
|
||
<td className="px-6 py-4 text-sm text-text-main font-mono">{user.username}</td>
|
||
<td className="px-6 py-4 text-sm text-text-main font-semibold">{user.name}</td>
|
||
<td className="px-6 py-4 text-sm text-text-main">{user.email || '-'}</td>
|
||
<td className="px-6 py-4 text-sm text-text-main">{user.phone || '-'}</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||
user.role === 'super' ? 'bg-amber-100 text-amber-700' :
|
||
user.role === 'admin' ? 'bg-blue-100 text-blue-700' :
|
||
'bg-green-100 text-green-700'
|
||
}`}>
|
||
{roleNames[user.role]}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-text-main">{user.department || '-'}</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||
user.signature ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'
|
||
}`}>
|
||
{user.signature ? '已上传' : '未上传'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||
user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||
}`}>
|
||
{user.status === 'active' ? '启用' : '禁用'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="flex gap-2 transition-opacity">
|
||
<button
|
||
onClick={() => handleEdit(user)}
|
||
className="p-2 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||
title="编辑"
|
||
>
|
||
<Edit size={16} />
|
||
</button>
|
||
{user.username !== 'admin' && user.username !== currentUser.username && (
|
||
<button
|
||
onClick={() => handleDelete(user)}
|
||
className="p-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||
title="删除"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{isModalOpen && (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||
<div className="bg-white rounded-2xl p-10 w-full max-w-[500px] max-h-[90vh] overflow-y-auto shadow-2xl border border-border">
|
||
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑用户' : '新增用户'}</h3>
|
||
<p className="text-sm text-text-muted mb-8">填写用户信息并分配系统权限。</p>
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">用户ID *</label>
|
||
<input
|
||
type="text"
|
||
value={formData.username}
|
||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||
disabled={isEditing}
|
||
required
|
||
className="input-minimal disabled:bg-slate-50 disabled:text-text-muted"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">姓名 *</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
required
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">电话</label>
|
||
<input
|
||
type="tel"
|
||
value={formData.phone}
|
||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">邮箱</label>
|
||
<input
|
||
type="email"
|
||
value={formData.email}
|
||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||
{isEditing ? '修改密码 (留空则不修改)' : '密码 *'}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formData.password}
|
||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||
required={!isEditing}
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
|
||
{isEditing && formData.password && (
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">确认新密码 *</label>
|
||
<input
|
||
type="password"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
required
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">角色 *</label>
|
||
<select
|
||
value={formData.role}
|
||
onChange={(e) => {
|
||
const newRole = e.target.value as any;
|
||
const allTplIds = allTemplates.map(t => t.id);
|
||
setFormData({
|
||
...formData,
|
||
role: newRole,
|
||
manageableTemplates: newRole === 'user' ? [] : allTplIds,
|
||
visibleTemplates: newRole === 'user' ? [] : allTplIds
|
||
});
|
||
}}
|
||
required
|
||
disabled={isAdminLogin || (isEditing && formData.username === 'admin')}
|
||
className="input-minimal bg-white disabled:bg-slate-50 disabled:text-text-muted"
|
||
>
|
||
<option value="user">医生</option>
|
||
{!isAdminLogin && <option value="admin">管理员</option>}
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">部门 *</label>
|
||
<input
|
||
type="text"
|
||
value={formData.department}
|
||
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||
disabled={isAdminLogin}
|
||
required
|
||
className="input-minimal disabled:bg-slate-50 disabled:text-text-muted"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">状态</label>
|
||
<select
|
||
value={formData.status}
|
||
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||
className="input-minimal bg-white"
|
||
>
|
||
<option value="active">启用</option>
|
||
<option value="inactive">禁用</option>
|
||
</select>
|
||
</div>
|
||
|
||
{needAuthKey && (
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">授权密钥 *</label>
|
||
<input
|
||
type="text"
|
||
value={authKey}
|
||
onChange={(e) => setAuthKey(e.target.value)}
|
||
required
|
||
placeholder="请输入授权密钥以禁用管理员"
|
||
className="input-minimal"
|
||
/>
|
||
<p className="text-[10px] text-text-muted">禁用管理员账号需要输入系统授权密钥进行验证。</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">电子签名</label>
|
||
{formData.signature ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={formData.signature}
|
||
alt="电子签名预览"
|
||
className="h-16 border border-border rounded bg-white object-contain"
|
||
/>
|
||
<div className="flex flex-col gap-2">
|
||
<label className="px-3 py-1.5 text-xs font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-1">
|
||
<Upload size={12} />
|
||
重新上传
|
||
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData(prev => ({ ...prev, signature: undefined }))}
|
||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors inline-flex items-center gap-1"
|
||
>
|
||
<X size={12} />
|
||
清除签名
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-3">
|
||
<label className="px-4 py-2 text-sm font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-2">
|
||
<Upload size={14} />
|
||
上传签名
|
||
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
|
||
</label>
|
||
<span className="text-xs text-text-muted">支持 JPG、PNG,自动压缩至 500px 以内</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{showManageableTemplates && (
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||
{formData.role === 'super' ? '可管理模板' : '可管理模板'}
|
||
</label>
|
||
<div className="max-h-[150px] overflow-y-auto border border-border rounded-lg p-3 space-y-2 bg-slate-50">
|
||
{allTemplates.map(tpl => (
|
||
<label key={tpl.id} className={`flex items-center gap-2 p-1 rounded-md transition-colors ${isManageableReadonly ? '' : 'cursor-pointer hover:bg-white'}`}>
|
||
<input
|
||
type="checkbox"
|
||
checked={(formData.manageableTemplates || []).includes(tpl.id)}
|
||
onChange={() => toggleTemplate(tpl.id, 'manageableTemplates')}
|
||
disabled={isManageableReadonly}
|
||
className="w-4 h-4 rounded-sm border-border text-accent focus:ring-accent disabled:opacity-50"
|
||
/>
|
||
<span className={`text-sm ${isManageableReadonly ? 'text-text-muted' : 'text-text-main'}`}>{tpl.name}</span>
|
||
</label>
|
||
))}
|
||
{allTemplates.length === 0 && <p className="text-xs text-text-muted italic">暂无模板</p>}
|
||
</div>
|
||
{isManageableReadonly && (
|
||
<p className="text-[10px] text-text-muted">
|
||
{formData.role === 'super' ? '超级管理员默认可管理所有模板,不可更改。' : '管理员的模板管理权限由超级管理员设定,不可自行更改。'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showVisibleTemplates && (
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||
{formData.role === 'super' ? '可视模板 (全部)' : '可视模板'}
|
||
</label>
|
||
<div className="max-h-[150px] overflow-y-auto border border-border rounded-lg p-3 space-y-2 bg-slate-50">
|
||
{visibleCandidates.map(tpl => (
|
||
<label key={tpl.id} className="flex items-center gap-2 cursor-pointer hover:bg-white p-1 rounded-md transition-colors">
|
||
<input
|
||
type="checkbox"
|
||
checked={(formData.visibleTemplates || []).includes(tpl.id)}
|
||
onChange={() => toggleTemplate(tpl.id, 'visibleTemplates')}
|
||
disabled={formData.role === 'super'}
|
||
className="w-4 h-4 rounded-sm border-border text-accent focus:ring-accent disabled:opacity-50"
|
||
/>
|
||
<span className={`text-sm ${formData.role === 'super' ? 'text-text-muted' : 'text-text-main'}`}>{tpl.name}</span>
|
||
</label>
|
||
))}
|
||
{visibleCandidates.length === 0 && <p className="text-xs text-text-muted italic">暂无可视模板</p>}
|
||
</div>
|
||
{formData.role === 'super' && (
|
||
<p className="text-[10px] text-text-muted">超级管理员默认可视所有模板。</p>
|
||
)}
|
||
{(isSuperEditingAdmin || isAdminEditingSelf) && (
|
||
<p className="text-[10px] text-text-muted">管理员的可视模板只能从其可管理模板中选择。</p>
|
||
)}
|
||
{(isSuperEditingUser || isAdminEditingUser) && (
|
||
<p className="text-[10px] text-text-muted">医生的可视模板只能从其部门管理员的可管理模板中选择。</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-accent"
|
||
>
|
||
保存用户
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|