30e100ef3e
2. 添加权限映射表和全局查看权限的hook,便于路由控制不同权限按钮显示/隐藏。 3. 删除评查点分组的部分旧api方法。 4. 对接评查点分组接口,文档类型接口, 提示词管理接口, 入口模块管理的接口。 5. 优化角色权限管理的接口,完善不用地区的访问权限认证。 6. 优化主页交叉评查和设置的入口样式和布局。 7. 优化评查点分组,评查规则的功能权限校验。
1842 lines
63 KiB
TypeScript
1842 lines
63 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
||
import { ClientLoaderFunctionArgs, ClientActionFunctionArgs } from "@remix-run/react";
|
||
import { Card } from "~/components/ui/Card";
|
||
import { Button } from "~/components/ui/Button";
|
||
import { Modal } from "~/components/ui/Modal";
|
||
import { toastService } from "~/components/ui/Toast";
|
||
import {
|
||
getRoles,
|
||
getRoutes,
|
||
getRoleRoutePermissions,
|
||
updateRoleRoutePermissions,
|
||
getRoleRoutesWithPermissions,
|
||
saveRoleApiPermissions,
|
||
getRolePermissions,
|
||
getRoleUsers,
|
||
getAllUsers,
|
||
assignUserRoles,
|
||
createRole,
|
||
updateRole,
|
||
deleteRole,
|
||
revokeUserRole,
|
||
getUserRoles,
|
||
type RoleInfo,
|
||
type RouteInfo,
|
||
type UserInfo,
|
||
type ApiPermission
|
||
} from "~/api/role-permissions/role-permissions";
|
||
import rolePermissionsStyles from "~/styles/pages/role-permissions.css?url";
|
||
|
||
// 引入样式
|
||
export function links() {
|
||
return [
|
||
{ rel: "stylesheet", href: rolePermissionsStyles }
|
||
];
|
||
}
|
||
|
||
// 页面元数据
|
||
export const meta = () => {
|
||
return [
|
||
{ title: "角色权限管理 - 中国烟草AI合同及卷宗审核系统" },
|
||
{ name: "description", content: "管理系统角色和权限分配" }
|
||
];
|
||
};
|
||
|
||
// ==================== 辅助函数 ====================
|
||
|
||
/**
|
||
* 数据范围转中文
|
||
*/
|
||
function getDataScopeLabel(scope: string): string {
|
||
const map: Record<string, string> = {
|
||
'ALL': '全部数据',
|
||
'DEPT': '部门数据',
|
||
'SELF': '仅本人数据'
|
||
};
|
||
return map[scope] || scope;
|
||
}
|
||
|
||
// ClientLoader - 加载初始数据
|
||
export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
|
||
// ==================== 权限校验 ====================
|
||
// 不在这里做权限检查,因为路由权限已经在 root.tsx 中检查过了
|
||
// 如果用户能访问到这个页面,说明已经通过了路由权限检查
|
||
//
|
||
// 原有的客户端权限检查逻辑已移除,统一使用 root.tsx 的路由权限控制
|
||
// 这样可以避免:
|
||
// 1. 路由权限通过但页面权限不通过的冲突
|
||
// 2. localStorage.user_info 数据不完整导致的误判
|
||
// 3. 重复的权限检查逻辑
|
||
|
||
// ==================== 加载数据 ====================
|
||
try {
|
||
const [roles, routes, users] = await Promise.all([
|
||
getRoles(),
|
||
getRoutes(),
|
||
getAllUsers()
|
||
]);
|
||
|
||
return {
|
||
roles,
|
||
routes,
|
||
users
|
||
};
|
||
} catch (error) {
|
||
console.error("加载数据失败:", error);
|
||
return {
|
||
roles: [],
|
||
routes: [],
|
||
users: []
|
||
};
|
||
}
|
||
}
|
||
|
||
// ClientAction - 处理用户操作
|
||
export async function clientAction({ request }: ClientActionFunctionArgs) {
|
||
const formData = await request.formData();
|
||
const action = formData.get("action") as string;
|
||
|
||
try {
|
||
switch (action) {
|
||
case "updatePermissions": {
|
||
const roleId = parseInt(formData.get("roleId") as string);
|
||
const routeIds = JSON.parse(formData.get("routeIds") as string);
|
||
const result = await updateRoleRoutePermissions(roleId, routeIds);
|
||
return result;
|
||
}
|
||
|
||
case "assignUserRoles": {
|
||
const userId = parseInt(formData.get("userId") as string);
|
||
const roleIds = JSON.parse(formData.get("roleIds") as string);
|
||
const result = await assignUserRoles(userId, roleIds);
|
||
return result;
|
||
}
|
||
|
||
case "createRole": {
|
||
const roleData = JSON.parse(formData.get("roleData") as string);
|
||
const result = await createRole(roleData);
|
||
return result;
|
||
}
|
||
|
||
case "updateRole": {
|
||
const roleId = parseInt(formData.get("roleId") as string);
|
||
const roleData = JSON.parse(formData.get("roleData") as string);
|
||
const result = await updateRole(roleId, roleData);
|
||
return result;
|
||
}
|
||
|
||
case "deleteRole": {
|
||
const roleId = parseInt(formData.get("roleId") as string);
|
||
const result = await deleteRole(roleId);
|
||
return result;
|
||
}
|
||
|
||
default:
|
||
return { success: false, message: "未知操作" };
|
||
}
|
||
} catch (error) {
|
||
console.error("操作失败:", error);
|
||
return {
|
||
success: false,
|
||
message: error instanceof Error ? error.message : "操作失败"
|
||
};
|
||
}
|
||
}
|
||
|
||
// ==================== 创建角色模态框 ====================
|
||
|
||
interface CreateRoleModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
function CreateRoleModal({ isOpen, onClose, onSuccess }: CreateRoleModalProps) {
|
||
const [formData, setFormData] = useState({
|
||
role_key: '',
|
||
role_name: '',
|
||
description: '',
|
||
data_scope: 'SELF' as 'ALL' | 'DEPT' | 'SELF',
|
||
priority: 10
|
||
});
|
||
|
||
const [loading, setLoading] = useState(false);
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
|
||
// 重置表单
|
||
const resetForm = () => {
|
||
setFormData({
|
||
role_key: '',
|
||
role_name: '',
|
||
description: '',
|
||
data_scope: 'SELF',
|
||
priority: 10
|
||
});
|
||
setErrors({});
|
||
};
|
||
|
||
// 验证表单
|
||
const validateForm = (): boolean => {
|
||
const newErrors: Record<string, string> = {};
|
||
|
||
if (!formData.role_key.trim()) {
|
||
newErrors.role_key = '角色标识不能为空';
|
||
} else if (!/^[a-z][a-z0-9_]*$/.test(formData.role_key)) {
|
||
newErrors.role_key = '角色标识只能包含小写字母、数字、下划线,且必须以字母开头';
|
||
}
|
||
|
||
if (!formData.role_name.trim()) {
|
||
newErrors.role_name = '角色名称不能为空';
|
||
}
|
||
|
||
setErrors(newErrors);
|
||
return Object.keys(newErrors).length === 0;
|
||
};
|
||
|
||
// 提交表单
|
||
const handleSubmit = async (e?: React.FormEvent) => {
|
||
if (e) e.preventDefault();
|
||
|
||
if (!validateForm()) {
|
||
toastService.error('请填写正确的表单信息');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await createRole({
|
||
role_key: formData.role_key,
|
||
role_name: formData.role_name,
|
||
description: formData.description,
|
||
data_scope: formData.data_scope,
|
||
priority: formData.priority,
|
||
is_system_role: false
|
||
});
|
||
|
||
if (result.success) {
|
||
toastService.success(result.message);
|
||
resetForm();
|
||
onSuccess();
|
||
onClose();
|
||
} else {
|
||
toastService.error(result.message);
|
||
}
|
||
} catch (error) {
|
||
toastService.error('创建角色失败');
|
||
console.error('创建角色失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 关闭时重置表单
|
||
const handleClose = () => {
|
||
resetForm();
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={handleClose}
|
||
title="创建新角色"
|
||
size="medium"
|
||
footer={
|
||
<>
|
||
<Button type="default" onClick={handleClose} disabled={loading}>
|
||
取消
|
||
</Button>
|
||
<Button type="primary" onClick={handleSubmit} disabled={loading}>
|
||
{loading ? '创建中...' : '创建'}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<form className="role-form" onSubmit={handleSubmit}>
|
||
<div className="form-group">
|
||
<label className="required">角色标识</label>
|
||
<input
|
||
type="text"
|
||
className={`form-input ${errors.role_key ? 'error' : ''}`}
|
||
placeholder="如: department_leader"
|
||
value={formData.role_key}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setFormData({ ...formData, role_key: value });
|
||
|
||
// 实时验证
|
||
if (value && !/^[a-z][a-z0-9_]*$/.test(value)) {
|
||
setErrors({ ...errors, role_key: '必须以字母开头,只能包含小写字母、数字、下划线' });
|
||
} else {
|
||
setErrors({ ...errors, role_key: '' });
|
||
}
|
||
}}
|
||
disabled={loading}
|
||
/>
|
||
{errors.role_key && <span className="form-error">{errors.role_key}</span>}
|
||
<span className="form-hint">必须以字母开头,只能包含小写字母、数字、下划线,创建后不可修改</span>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="required">角色名称</label>
|
||
<input
|
||
type="text"
|
||
className={`form-input ${errors.role_name ? 'error' : ''}`}
|
||
placeholder="如: 部门负责人"
|
||
value={formData.role_name}
|
||
onChange={(e) => {
|
||
setFormData({ ...formData, role_name: e.target.value });
|
||
setErrors({ ...errors, role_name: '' });
|
||
}}
|
||
disabled={loading}
|
||
/>
|
||
{errors.role_name && <span className="form-error">{errors.role_name}</span>}
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>角色描述</label>
|
||
<textarea
|
||
className="form-textarea"
|
||
placeholder="描述角色的职责和权限范围"
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
rows={3}
|
||
disabled={loading}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="required">数据范围</label>
|
||
<select
|
||
className="form-select"
|
||
value={formData.data_scope}
|
||
onChange={(e) => setFormData({ ...formData, data_scope: e.target.value as any })}
|
||
disabled={loading}
|
||
>
|
||
<option value="SELF">仅本人数据</option>
|
||
<option value="DEPT">部门数据</option>
|
||
<option value="ALL">全部数据</option>
|
||
</select>
|
||
<span className="form-hint">控制该角色可访问的数据范围</span>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>优先级</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
placeholder="数字越小优先级越高"
|
||
value={formData.priority}
|
||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 10 })}
|
||
min={1}
|
||
max={100}
|
||
disabled={loading}
|
||
/>
|
||
<span className="form-hint">数字越小优先级越高,范围 1-100</span>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ==================== 编辑角色模态框 ====================
|
||
|
||
interface EditRoleModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
role: RoleInfo | null;
|
||
}
|
||
|
||
function EditRoleModal({ isOpen, onClose, onSuccess, role }: EditRoleModalProps) {
|
||
const [formData, setFormData] = useState({
|
||
role_name: '',
|
||
description: '',
|
||
data_scope: 'SELF' as 'ALL' | 'DEPT' | 'SELF',
|
||
priority: 10
|
||
});
|
||
|
||
const [loading, setLoading] = useState(false);
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
|
||
// 当角色数据变化时,更新表单
|
||
useEffect(() => {
|
||
if (role && isOpen) {
|
||
setFormData({
|
||
role_name: role.role_name,
|
||
description: role.description || '',
|
||
data_scope: role.data_scope as any,
|
||
priority: role.priority
|
||
});
|
||
setErrors({});
|
||
}
|
||
}, [role, isOpen]);
|
||
|
||
// 验证表单
|
||
const validateForm = (): boolean => {
|
||
const newErrors: Record<string, string> = {};
|
||
|
||
if (!formData.role_name.trim()) {
|
||
newErrors.role_name = '角色名称不能为空';
|
||
}
|
||
|
||
setErrors(newErrors);
|
||
return Object.keys(newErrors).length === 0;
|
||
};
|
||
|
||
// 提交表单
|
||
const handleSubmit = async (e?: React.FormEvent) => {
|
||
if (e) e.preventDefault();
|
||
|
||
if (!role) return;
|
||
|
||
if (!validateForm()) {
|
||
toastService.error('请填写正确的表单信息');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await updateRole(role.id, {
|
||
role_name: formData.role_name,
|
||
description: formData.description,
|
||
data_scope: formData.data_scope,
|
||
priority: formData.priority
|
||
});
|
||
|
||
if (result.success) {
|
||
toastService.success(result.message);
|
||
onSuccess();
|
||
onClose();
|
||
} else {
|
||
toastService.error(result.message);
|
||
}
|
||
} catch (error) {
|
||
toastService.error('更新角色失败');
|
||
console.error('更新角色失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (!role) return null;
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
title={`编辑角色 - ${role.role_key}`}
|
||
size="medium"
|
||
footer={
|
||
<>
|
||
<Button type="default" onClick={onClose} disabled={loading}>
|
||
取消
|
||
</Button>
|
||
<Button type="primary" onClick={handleSubmit} disabled={loading}>
|
||
{loading ? '保存中...' : '保存'}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<form className="role-form" onSubmit={handleSubmit}>
|
||
{role.is_system_role && (
|
||
<div className="form-notice warning">
|
||
<i className="ri-information-line"></i>
|
||
<span>系统角色的某些字段不可修改</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="form-group">
|
||
<label>角色标识</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={role.role_key}
|
||
disabled
|
||
style={{ background: '#f5f7fa', cursor: 'not-allowed' }}
|
||
/>
|
||
<span className="form-hint">角色标识创建后不可修改</span>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="required">角色名称</label>
|
||
<input
|
||
type="text"
|
||
className={`form-input ${errors.role_name ? 'error' : ''}`}
|
||
placeholder="如: 部门负责人"
|
||
value={formData.role_name}
|
||
onChange={(e) => {
|
||
setFormData({ ...formData, role_name: e.target.value });
|
||
setErrors({ ...errors, role_name: '' });
|
||
}}
|
||
disabled={loading}
|
||
/>
|
||
{errors.role_name && <span className="form-error">{errors.role_name}</span>}
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>角色描述</label>
|
||
<textarea
|
||
className="form-textarea"
|
||
placeholder="描述角色的职责和权限范围"
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
rows={3}
|
||
disabled={loading}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label className="required">数据范围</label>
|
||
<select
|
||
className="form-select"
|
||
value={formData.data_scope}
|
||
onChange={(e) => setFormData({ ...formData, data_scope: e.target.value as any })}
|
||
disabled={loading || (role.is_system_role && role.role_key === 'provincial_admin')}
|
||
>
|
||
<option value="SELF">仅本人数据</option>
|
||
<option value="DEPT">部门数据</option>
|
||
<option value="ALL">全部数据</option>
|
||
</select>
|
||
{role.is_system_role && role.role_key === 'provincial_admin' && (
|
||
<span className="form-hint">省级管理员的数据范围不可修改</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>优先级</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
placeholder="数字越小优先级越高"
|
||
value={formData.priority}
|
||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 10 })}
|
||
min={1}
|
||
max={100}
|
||
disabled={loading}
|
||
/>
|
||
<span className="form-hint">数字越小优先级越高,范围 1-100</span>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ==================== 分配用户模态框 ====================
|
||
|
||
interface AssignUserModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
role: RoleInfo | null;
|
||
isCityAdmin?: boolean;
|
||
currentUserArea?: string;
|
||
}
|
||
|
||
function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, currentUserArea }: AssignUserModalProps) {
|
||
const [allUsers, setAllUsers] = useState<UserInfo[]>([]);
|
||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||
// 存储每个用户的角色信息
|
||
const [userRolesMap, setUserRolesMap] = useState<Map<number, RoleInfo[]>>(new Map());
|
||
|
||
// 当模态框打开时,加载用户列表
|
||
useEffect(() => {
|
||
if (isOpen && role) {
|
||
loadUsers();
|
||
}
|
||
}, [isOpen, role]);
|
||
|
||
// 加载所有用户及其角色信息
|
||
const loadUsers = async () => {
|
||
setLoadingUsers(true);
|
||
try {
|
||
const users = await getAllUsers();
|
||
|
||
// v3.3: 市级管理员只能看到同地区的用户(使用 area 字段)
|
||
let filteredUsers = users;
|
||
if (isCityAdmin && currentUserArea) {
|
||
filteredUsers = users.filter(user => user.area === currentUserArea);
|
||
console.log('🔒 [AssignUserModal v3.3] 市级管理员用户过滤:', {
|
||
当前地区: currentUserArea,
|
||
原始用户数: users.length,
|
||
过滤后用户数: filteredUsers.length
|
||
});
|
||
}
|
||
|
||
setAllUsers(filteredUsers);
|
||
|
||
// 批量获取每个用户的角色
|
||
const rolesMap = new Map<number, RoleInfo[]>();
|
||
await Promise.all(
|
||
filteredUsers.map(async (user) => {
|
||
const roles = await getUserRoles(user.id);
|
||
rolesMap.set(user.id, roles);
|
||
})
|
||
);
|
||
setUserRolesMap(rolesMap);
|
||
} catch (error) {
|
||
console.error('加载用户列表失败:', error);
|
||
toastService.error('加载用户列表失败');
|
||
} finally {
|
||
setLoadingUsers(false);
|
||
}
|
||
};
|
||
|
||
// 重置状态
|
||
const resetState = () => {
|
||
setSelectedUserIds([]);
|
||
setSearchTerm('');
|
||
};
|
||
|
||
// 提交分配
|
||
const handleSubmit = async () => {
|
||
if (!role) return;
|
||
|
||
if (selectedUserIds.length === 0) {
|
||
toastService.error('请至少选择一个用户');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
// 检查每个用户是否已有角色
|
||
const usersWithRoles: Array<{ userId: number; userName: string; roleName: string }> = [];
|
||
|
||
for (const userId of selectedUserIds) {
|
||
const userRoles = await getUserRoles(userId);
|
||
if (userRoles.length > 0) {
|
||
const user = allUsers.find(u => u.id === userId);
|
||
usersWithRoles.push({
|
||
userId,
|
||
userName: user?.nick_name || user?.username || `用户${userId}`,
|
||
roleName: userRoles.map(r => r.role_name).join('、')
|
||
});
|
||
}
|
||
}
|
||
|
||
// 如果有用户已经有角色,阻止分配并提示
|
||
if (usersWithRoles.length > 0) {
|
||
const userList = usersWithRoles.map(u => `• ${u.userName} (当前角色: ${u.roleName})`).join('\n');
|
||
toastService.error(
|
||
`以下用户已有角色,不能同时拥有多个角色:\n${userList}\n\n请先移除其现有角色后再分配新角色。`
|
||
);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 为每个选中的用户分配角色
|
||
const promises = selectedUserIds.map(userId =>
|
||
assignUserRoles(userId, [role.id])
|
||
);
|
||
|
||
await Promise.all(promises);
|
||
|
||
toastService.success(`成功为 ${selectedUserIds.length} 个用户分配角色`);
|
||
resetState();
|
||
onSuccess();
|
||
onClose();
|
||
} catch (error) {
|
||
console.error('分配用户角色失败:', error);
|
||
toastService.error('分配用户角色失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 关闭时重置
|
||
const handleClose = () => {
|
||
resetState();
|
||
onClose();
|
||
};
|
||
|
||
// 全选/取消全选
|
||
const handleToggleAll = () => {
|
||
const filteredUserIds = filteredUsers.map(u => u.id);
|
||
if (selectedUserIds.length === filteredUserIds.length) {
|
||
setSelectedUserIds([]);
|
||
} else {
|
||
setSelectedUserIds(filteredUserIds);
|
||
}
|
||
};
|
||
|
||
if (!role) return null;
|
||
|
||
// 过滤用户
|
||
const filteredUsers = allUsers.filter(user => {
|
||
const searchLower = searchTerm.toLowerCase();
|
||
return (
|
||
user.nick_name.toLowerCase().includes(searchLower) ||
|
||
user.username.toLowerCase().includes(searchLower) ||
|
||
user.ou_name.toLowerCase().includes(searchLower)
|
||
);
|
||
});
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={handleClose}
|
||
title={`为角色"${role.role_name}"分配用户`}
|
||
size="medium"
|
||
footer={
|
||
<>
|
||
<Button type="default" onClick={handleClose} disabled={loading}>
|
||
取消
|
||
</Button>
|
||
<Button type="primary" onClick={handleSubmit} disabled={loading || selectedUserIds.length === 0}>
|
||
{loading ? '分配中...' : `分配 (${selectedUserIds.length})`}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="assign-user-modal">
|
||
{/* v3.3: 市级管理员地区过滤提示 */}
|
||
{isCityAdmin && currentUserArea && (
|
||
<div className="form-notice info" style={{ marginBottom: '12px' }}>
|
||
<i className="ri-information-line"></i>
|
||
<span>市级管理员权限:仅显示 {currentUserArea} 地区的用户</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 搜索框 */}
|
||
<div className="search-box">
|
||
<i className="ri-search-line"></i>
|
||
<input
|
||
type="text"
|
||
placeholder="搜索用户(姓名、用户名、单位)"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* 全选按钮 */}
|
||
<div className="select-all-bar">
|
||
<label className="user-checkbox-item select-all">
|
||
<input
|
||
type="checkbox"
|
||
checked={filteredUsers.length > 0 && selectedUserIds.length === filteredUsers.length}
|
||
onChange={handleToggleAll}
|
||
disabled={filteredUsers.length === 0}
|
||
/>
|
||
<span className="user-name">
|
||
全选 ({selectedUserIds.length} / {filteredUsers.length})
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* 用户复选框列表 */}
|
||
{loadingUsers ? (
|
||
<div className="loading-container" style={{ minHeight: '300px' }}>
|
||
<i className="ri-loader-4-line spin"></i>
|
||
<span>加载用户中...</span>
|
||
</div>
|
||
) : (
|
||
<div className="users-checkbox-list">
|
||
{filteredUsers.length > 0 ? (
|
||
filteredUsers.map(user => {
|
||
const userRoles = userRolesMap.get(user.id) || [];
|
||
const hasRoles = userRoles.length > 0;
|
||
|
||
return (
|
||
<label key={user.id} className="user-checkbox-item">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedUserIds.includes(user.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedUserIds([...selectedUserIds, user.id]);
|
||
} else {
|
||
setSelectedUserIds(selectedUserIds.filter(id => id !== user.id));
|
||
}
|
||
}}
|
||
/>
|
||
<div className="user-info">
|
||
<div className="user-name">
|
||
{user.nick_name}
|
||
{user.is_leader && (
|
||
<span className="leader-badge" style={{ marginLeft: '8px' }}>负责人</span>
|
||
)}
|
||
{hasRoles && (
|
||
<span
|
||
className="role-badge"
|
||
style={{
|
||
marginLeft: '8px',
|
||
padding: '2px 8px',
|
||
fontSize: '12px',
|
||
backgroundColor: '#f0f9ff',
|
||
color: '#0284c7',
|
||
border: '1px solid #bae6fd',
|
||
borderRadius: '4px'
|
||
}}
|
||
title={`当前角色: ${userRoles.map(r => r.role_name).join('、')}`}
|
||
>
|
||
{userRoles.map(r => r.role_name).join('、')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="user-meta">
|
||
@{user.username} • {user.ou_name}
|
||
{user.phone_number && ` • ${user.phone_number}`}
|
||
</div>
|
||
</div>
|
||
</label>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="empty-state" style={{ padding: '40px 20px' }}>
|
||
<i className="ri-user-search-line"></i>
|
||
<p>{searchTerm ? '没有找到匹配的用户' : '没有可分配的用户'}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// 主组件
|
||
export default function RolePermissions() {
|
||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||
const [routes, setRoutes] = useState<RouteInfo[]>([]);
|
||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
|
||
const [activeTab, setActiveTab] = useState<'permissions' | 'users'>('permissions');
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// v3.3: 检查当前用户角色和地区
|
||
const [currentUserRole, setCurrentUserRole] = useState('');
|
||
const [currentUserArea, setCurrentUserArea] = useState('');
|
||
const [isProvincialAdmin, setIsProvincialAdmin] = useState(false);
|
||
const [isCityAdmin, setIsCityAdmin] = useState(false);
|
||
|
||
// 模态框状态
|
||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [roleToEdit, setRoleToEdit] = useState<RoleInfo | null>(null);
|
||
const [showAssignUserModal, setShowAssignUserModal] = useState(false);
|
||
|
||
// 确认删除Modal状态
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState<{
|
||
type: 'role' | 'userRole';
|
||
role?: RoleInfo;
|
||
user?: UserInfo;
|
||
userCount?: number; // 删除角色时,关联的用户数量
|
||
} | null>(null);
|
||
const [deleteCountdown, setDeleteCountdown] = useState(3);
|
||
|
||
// 权限警告Modal状态
|
||
const [showPermissionWarning, setShowPermissionWarning] = useState(false);
|
||
const [pendingRouteChange, setPendingRouteChange] = useState<{
|
||
routeId: number;
|
||
checked: boolean;
|
||
} | null>(null);
|
||
|
||
// 路由权限相关状态
|
||
const [selectedRouteIds, setSelectedRouteIds] = useState<number[]>([]);
|
||
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
|
||
|
||
// v3.0: API权限相关状态
|
||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<number[]>([]);
|
||
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
|
||
// 存储每个路由的 permissions(routeId -> permissions[])
|
||
const [routePermissionsMap, setRoutePermissionsMap] = useState<Map<number, ApiPermission[]>>(new Map());
|
||
|
||
// 加载初始数据
|
||
useEffect(() => {
|
||
loadData();
|
||
}, []);
|
||
|
||
// 删除确认倒计时
|
||
useEffect(() => {
|
||
let timer: NodeJS.Timeout;
|
||
if (showDeleteConfirm && deleteCountdown > 0) {
|
||
timer = setTimeout(() => {
|
||
setDeleteCountdown(deleteCountdown - 1);
|
||
}, 1000);
|
||
}
|
||
return () => clearTimeout(timer);
|
||
}, [showDeleteConfirm, deleteCountdown]);
|
||
|
||
const loadData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
// v3.3: 检查当前用户角色和地区
|
||
if (typeof window !== 'undefined') {
|
||
const userInfoStr = localStorage.getItem('user_info');
|
||
if (userInfoStr) {
|
||
try {
|
||
const userInfo = JSON.parse(userInfoStr);
|
||
const userRole = userInfo.user_role || '';
|
||
const userArea = userInfo.area || ''; // v3.3: 使用 area 字段进行地区隔离
|
||
|
||
setCurrentUserRole(userRole);
|
||
setCurrentUserArea(userArea);
|
||
setIsProvincialAdmin(userRole === 'provincial_admin');
|
||
setIsCityAdmin(userRole === 'admin');
|
||
|
||
console.log('🔑 [RolePermissions v3.3] 当前用户信息:', {
|
||
role: userRole,
|
||
area: userArea,
|
||
isProvincialAdmin: userRole === 'provincial_admin',
|
||
isCityAdmin: userRole === 'admin'
|
||
});
|
||
} catch (e) {
|
||
console.error('❌ [RolePermissions] 解析用户信息失败:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
const [rolesData, routesData, usersData] = await Promise.all([
|
||
getRoles(),
|
||
getRoutes(),
|
||
getAllUsers()
|
||
]);
|
||
|
||
// v3.3: 角色列表对所有人可见(不过滤)
|
||
const filteredRoles = rolesData;
|
||
|
||
// v3.3: 根据用户地区过滤可见的用户列表
|
||
let filteredUsers = usersData;
|
||
if (isCityAdmin && currentUserArea) {
|
||
// 市级管理员只能看到同地区的用户(使用 area 字段)
|
||
filteredUsers = usersData.filter(user =>
|
||
user.area === currentUserArea
|
||
);
|
||
console.log('🔒 [RolePermissions v3.3] 市级管理员用户过滤:', {
|
||
当前地区: currentUserArea,
|
||
原始用户数: usersData.length,
|
||
过滤后用户数: filteredUsers.length
|
||
});
|
||
}
|
||
|
||
setRoles(filteredRoles);
|
||
setRoutes(routesData);
|
||
setUsers(filteredUsers);
|
||
|
||
// 默认选中第一个角色(使用过滤后的列表)
|
||
if (filteredRoles.length > 0) {
|
||
handleSelectRole(filteredRoles[0]);
|
||
}
|
||
} catch (error) {
|
||
console.error("加载数据失败:", error);
|
||
toastService.error("加载数据失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 选择角色
|
||
const handleSelectRole = async (role: RoleInfo) => {
|
||
setSelectedRole(role);
|
||
|
||
// v3.0: 并行加载数据
|
||
const [routesResult, rolePermissions, users] = await Promise.all([
|
||
getRoleRoutesWithPermissions(role.id),
|
||
getRolePermissions(role.id), // 获取该角色已分配的权限
|
||
getRoleUsers(role.id)
|
||
]);
|
||
|
||
const { routes: routesWithPerms, selectedRouteIds: routeIds } = routesResult;
|
||
|
||
// 构建 routePermissionsMap:从返回的路由中提取每个路由的可用 permissions
|
||
const permMap = new Map<number, ApiPermission[]>();
|
||
const extractPermissions = (routes: RouteInfo[]) => {
|
||
routes.forEach(route => {
|
||
if (route.permissions && route.permissions.length > 0) {
|
||
permMap.set(route.id, route.permissions);
|
||
}
|
||
if (route.children) {
|
||
extractPermissions(route.children);
|
||
}
|
||
});
|
||
};
|
||
extractPermissions(routesWithPerms);
|
||
|
||
// 从 getRolePermissions 结果中提取已分配的权限ID
|
||
const assignedPermissionIds = rolePermissions.map(p => p.permission_id);
|
||
|
||
setRoutePermissionsMap(permMap);
|
||
setSelectedRouteIds(routeIds);
|
||
setSelectedPermissionIds(assignedPermissionIds); // 使用实际已分配的权限ID
|
||
setExpandedRouteIds([]); // 重置展开状态
|
||
setRoleUsers(users);
|
||
};
|
||
|
||
// 递归查找路由
|
||
const findRouteById = (routes: RouteInfo[], routeId: number): RouteInfo | null => {
|
||
for (const route of routes) {
|
||
if (route.id === routeId) {
|
||
return route;
|
||
}
|
||
if (route.children && route.children.length > 0) {
|
||
const found = findRouteById(route.children, routeId);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 递归获取所有路由ID(包括子路由)
|
||
const getAllRouteIds = (routes: RouteInfo[]): number[] => {
|
||
let ids: number[] = [];
|
||
routes.forEach(route => {
|
||
ids.push(route.id);
|
||
if (route.children && route.children.length > 0) {
|
||
ids = ids.concat(getAllRouteIds(route.children));
|
||
}
|
||
});
|
||
return ids;
|
||
};
|
||
|
||
// 递归检查路由树中是否包含指定路径的路由
|
||
const containsRoutePath = (routes: RouteInfo[], targetPath: string): boolean => {
|
||
for (const route of routes) {
|
||
if (route.route_path === targetPath) {
|
||
return true;
|
||
}
|
||
if (route.children && route.children.length > 0) {
|
||
if (containsRoutePath(route.children, targetPath)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// 切换路由权限
|
||
const handleToggleRoute = (routeId: number, checked: boolean) => {
|
||
// 检查是否正在取消勾选 /role-permissions 路由
|
||
if (!checked) {
|
||
const route = findRouteById(routes, routeId);
|
||
if (route && route.route_path === '/role-permissions') {
|
||
// 显示警告模态框
|
||
setPendingRouteChange({ routeId, checked });
|
||
setShowPermissionWarning(true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (checked) {
|
||
setSelectedRouteIds([...selectedRouteIds, routeId]);
|
||
} else {
|
||
setSelectedRouteIds(selectedRouteIds.filter(id => id !== routeId));
|
||
}
|
||
};
|
||
|
||
// 切换父路由(包括所有子路由)
|
||
const handleToggleParentRoute = (route: RouteInfo, checked: boolean) => {
|
||
// 检查是否正在取消勾选包含 /role-permissions 的父路由
|
||
if (!checked) {
|
||
const allRoutes = route.children ? [route, ...route.children] : [route];
|
||
const hasRolePermissionsRoute = allRoutes.some(r => r.route_path === '/role-permissions') ||
|
||
(route.children && containsRoutePath(route.children, '/role-permissions'));
|
||
|
||
if (route.route_path === '/role-permissions' || hasRolePermissionsRoute) {
|
||
// 显示警告模态框,传递 route 对象表示是父路由操作
|
||
setPendingRouteChange({ routeId: route.id, checked });
|
||
setShowPermissionWarning(true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const childIds = route.children ? getAllRouteIds(route.children) : [];
|
||
const allIds = [route.id, ...childIds];
|
||
|
||
if (checked) {
|
||
const newIds = [...selectedRouteIds, ...allIds].filter(
|
||
(id, index, self) => self.indexOf(id) === index
|
||
);
|
||
setSelectedRouteIds(newIds);
|
||
} else {
|
||
setSelectedRouteIds(
|
||
selectedRouteIds.filter(id => !allIds.includes(id))
|
||
);
|
||
}
|
||
};
|
||
|
||
// 确认取消角色权限管理路由
|
||
const confirmRemovePermissionRoute = () => {
|
||
if (!pendingRouteChange) return;
|
||
|
||
const { routeId, checked } = pendingRouteChange;
|
||
const route = findRouteById(routes, routeId);
|
||
|
||
if (route) {
|
||
// 如果是父路由,取消所有子路由
|
||
if (route.children && route.children.length > 0) {
|
||
const childIds = getAllRouteIds(route.children);
|
||
const allIds = [route.id, ...childIds];
|
||
setSelectedRouteIds(selectedRouteIds.filter(id => !allIds.includes(id)));
|
||
} else {
|
||
// 单个路由
|
||
setSelectedRouteIds(selectedRouteIds.filter(id => id !== routeId));
|
||
}
|
||
}
|
||
|
||
// 关闭模态框并重置状态
|
||
setShowPermissionWarning(false);
|
||
setPendingRouteChange(null);
|
||
|
||
toastService.warning('已取消角色权限管理路由,请谨慎保存权限配置');
|
||
};
|
||
|
||
// v3.0: 切换路由展开状态(显示/隐藏权限列表)
|
||
const handleToggleRouteExpand = (routeId: number) => {
|
||
setExpandedRouteIds(prev =>
|
||
prev.includes(routeId)
|
||
? prev.filter(id => id !== routeId)
|
||
: [...prev, routeId]
|
||
);
|
||
};
|
||
|
||
// v3.0: 判断是否是"所有权限"项(用于过滤)
|
||
const isAllPermission = (permission: ApiPermission): boolean => {
|
||
const key = permission.permission_key?.toLowerCase() || '';
|
||
const name = permission.display_name || '';
|
||
return key.includes(':all:') || key.includes(':*:') ||
|
||
key.endsWith(':all') || key.endsWith(':*') ||
|
||
name.includes('所有权限') || name.includes('全部权限');
|
||
};
|
||
|
||
// v3.0: 过滤掉"所有权限"项
|
||
const filterPermissions = (permissions: ApiPermission[]): ApiPermission[] => {
|
||
return permissions.filter(p => !isAllPermission(p));
|
||
};
|
||
|
||
// v3.0: 切换单个API权限
|
||
const handleTogglePermission = (permissionId: number, checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedPermissionIds([...selectedPermissionIds, permissionId]);
|
||
} else {
|
||
setSelectedPermissionIds(selectedPermissionIds.filter(id => id !== permissionId));
|
||
}
|
||
};
|
||
|
||
// v3.0: 获取HTTP方法对应的标签样式
|
||
const getMethodTagStyle = (method: string): React.CSSProperties => {
|
||
const styles: Record<string, React.CSSProperties> = {
|
||
'GET': { backgroundColor: '#e6f7ed', color: '#52c41a', border: '1px solid #b7eb8f' },
|
||
'POST': { backgroundColor: '#e6f0ff', color: '#1890ff', border: '1px solid #91caff' },
|
||
'PUT': { backgroundColor: '#fff7e6', color: '#faad14', border: '1px solid #ffd591' },
|
||
'DELETE': { backgroundColor: '#fff1f0', color: '#f5222d', border: '1px solid #ffa39e' },
|
||
'PATCH': { backgroundColor: '#f0f5ff', color: '#722ed1', border: '1px solid #d3adf7' }
|
||
};
|
||
return styles[method.toUpperCase()] || { backgroundColor: '#f5f5f5', color: '#666', border: '1px solid #d9d9d9' };
|
||
};
|
||
|
||
// 编辑角色
|
||
const handleEditRole = (role: RoleInfo) => {
|
||
setRoleToEdit(role);
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
// 删除角色 - 显示确认Modal
|
||
const handleDeleteRole = async (role: RoleInfo) => {
|
||
// 系统角色禁止删除
|
||
if (role.is_system_role) {
|
||
toastService.error('系统角色不能删除');
|
||
return;
|
||
}
|
||
|
||
// 核心角色保护
|
||
const coreRoles = ['provincial_admin', 'admin', 'common'];
|
||
if (coreRoles.includes(role.role_key)) {
|
||
toastService.error('系统核心角色不能删除');
|
||
return;
|
||
}
|
||
|
||
// 获取该角色关联的用户数量
|
||
const users = await getRoleUsers(role.id);
|
||
const userCount = users.length;
|
||
|
||
// 打开确认删除Modal
|
||
setDeleteTarget({ type: 'role', role, userCount });
|
||
setDeleteCountdown(3);
|
||
setShowDeleteConfirm(true);
|
||
};
|
||
|
||
// 确认删除角色 - 实际执行删除
|
||
const confirmDeleteRole = async () => {
|
||
if (!deleteTarget || deleteTarget.type !== 'role' || !deleteTarget.role) return;
|
||
|
||
const role = deleteTarget.role;
|
||
const userCount = deleteTarget.userCount || 0;
|
||
setShowDeleteConfirm(false);
|
||
setDeleteTarget(null);
|
||
|
||
try {
|
||
const result = await deleteRole(role.id, false);
|
||
|
||
if (result.success) {
|
||
// 根据是否有用户解绑,显示不同的成功提示
|
||
if (userCount > 0) {
|
||
toastService.success(`角色删除成功,已自动解除 ${userCount} 个用户的角色绑定`);
|
||
} else {
|
||
toastService.success(result.message);
|
||
}
|
||
// 重新加载数据
|
||
await loadData();
|
||
// 如果删除的是当前选中的角色,清除选中状态
|
||
if (selectedRole?.id === role.id) {
|
||
setSelectedRole(null);
|
||
}
|
||
} else {
|
||
toastService.error(result.message);
|
||
}
|
||
} catch (error) {
|
||
console.error('删除角色失败:', error);
|
||
toastService.error('删除角色失败');
|
||
}
|
||
};
|
||
|
||
// 移除用户角色 - 显示确认Modal
|
||
const handleRemoveUserRole = (user: UserInfo) => {
|
||
if (!selectedRole) return;
|
||
|
||
// 打开确认删除Modal
|
||
setDeleteTarget({ type: 'userRole', role: selectedRole, user });
|
||
setDeleteCountdown(3);
|
||
setShowDeleteConfirm(true);
|
||
};
|
||
|
||
// 确认移除用户角色 - 实际执行
|
||
const confirmRemoveUserRole = async () => {
|
||
if (!deleteTarget || deleteTarget.type !== 'userRole' || !deleteTarget.role || !deleteTarget.user) return;
|
||
|
||
const { role, user } = deleteTarget;
|
||
setShowDeleteConfirm(false);
|
||
setDeleteTarget(null);
|
||
|
||
try {
|
||
const result = await revokeUserRole(user.id, role.id);
|
||
|
||
if (result.success) {
|
||
toastService.success(result.message);
|
||
// 重新加载该角色的用户列表
|
||
const users = await getRoleUsers(role.id);
|
||
setRoleUsers(users);
|
||
} else {
|
||
toastService.error(result.message);
|
||
}
|
||
} catch (error) {
|
||
console.error('移除用户角色失败:', error);
|
||
toastService.error('移除用户角色失败');
|
||
}
|
||
};
|
||
|
||
// 保存权限 - v3.3: 同时保存路由权限和API权限,仅省级管理员可操作
|
||
const handleSavePermissions = async () => {
|
||
if (!selectedRole) return;
|
||
|
||
// v3.3: 前置权限检查(仅省级管理员)
|
||
if (!isProvincialAdmin) {
|
||
toastService.error('权限不足:仅省级管理员可以修改角色路由权限');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 1. 保存路由权限
|
||
const routeResult = await updateRoleRoutePermissions(selectedRole.id, selectedRouteIds);
|
||
|
||
// v3.3: 处理权限不足错误
|
||
if (!routeResult.success) {
|
||
if (routeResult.code === 4003) {
|
||
toastService.error('权限不足:仅省级管理员可以修改角色路由权限');
|
||
} else {
|
||
toastService.error(routeResult.message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 2. 保存API权限(如果有选中的权限)
|
||
if (selectedPermissionIds.length > 0) {
|
||
const permResult = await saveRoleApiPermissions(selectedRole.id, selectedPermissionIds);
|
||
|
||
if (!permResult.success) {
|
||
toastService.error(permResult.message);
|
||
return;
|
||
}
|
||
|
||
toastService.success(`路由权限保存成功,${permResult.message}`);
|
||
} else {
|
||
// 没有选中API权限时,清空该角色的所有API权限
|
||
const permResult = await saveRoleApiPermissions(selectedRole.id, []);
|
||
|
||
toastService.success(routeResult.message);
|
||
}
|
||
} catch (error) {
|
||
console.error("保存权限失败:", error);
|
||
toastService.error("保存权限失败");
|
||
}
|
||
};
|
||
|
||
// 渲染路由树 - v3.0: 支持展开显示API权限
|
||
const renderRouteTree = (routeList: RouteInfo[], level = 0) => {
|
||
return routeList.map(route => {
|
||
const hasChildren = route.children && route.children.length > 0;
|
||
// v3.0: 从 routePermissionsMap 获取该路由的 permissions,并过滤掉"所有权限"
|
||
const rawPermissions = routePermissionsMap.get(route.id) || [];
|
||
const permissions = filterPermissions(rawPermissions);
|
||
const hasPermissions = permissions.length > 0;
|
||
const isChecked = selectedRouteIds.includes(route.id);
|
||
const isExpanded = expandedRouteIds.includes(route.id);
|
||
const allChildIds = hasChildren ? getAllRouteIds(route.children!) : [];
|
||
const checkedChildCount = allChildIds.filter(id =>
|
||
selectedRouteIds.includes(id)
|
||
).length;
|
||
const isIndeterminate =
|
||
hasChildren && checkedChildCount > 0 && checkedChildCount < allChildIds.length;
|
||
|
||
// 计算该路由下已选中的权限数量(使用过滤后的权限)
|
||
const routePermissionIds = permissions.map(p => p.id);
|
||
const selectedPermCount = routePermissionIds.filter(id =>
|
||
selectedPermissionIds.includes(id)
|
||
).length;
|
||
|
||
return (
|
||
<div key={route.id} className="route-item" style={{ paddingLeft: `${level * 20}px` }}>
|
||
<div className="route-item-content">
|
||
<input
|
||
type="checkbox"
|
||
id={`route-${route.id}`}
|
||
checked={isChecked}
|
||
ref={el => {
|
||
if (el) el.indeterminate = isIndeterminate;
|
||
}}
|
||
onChange={(e) => {
|
||
if (hasChildren) {
|
||
handleToggleParentRoute(route, e.target.checked);
|
||
} else {
|
||
handleToggleRoute(route.id, e.target.checked);
|
||
}
|
||
}}
|
||
className="route-checkbox"
|
||
disabled={!isProvincialAdmin}
|
||
/>
|
||
<label htmlFor={`route-${route.id}`} className="route-label">
|
||
{route.icon && <i className={`${route.icon} route-icon`}></i>}
|
||
<span className="route-title">{route.route_title}</span>
|
||
<span className="route-path">{route.route_path}</span>
|
||
</label>
|
||
|
||
{/* v3.0: 显示权限展开按钮 */}
|
||
{hasPermissions && (
|
||
<button
|
||
type="button"
|
||
className="permission-expand-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleToggleRouteExpand(route.id);
|
||
}}
|
||
style={{
|
||
marginLeft: '8px',
|
||
padding: '2px 8px',
|
||
fontSize: '12px',
|
||
backgroundColor: selectedPermCount > 0 ? '#e6f7ed' : '#f5f5f5',
|
||
color: selectedPermCount > 0 ? '#52c41a' : '#666',
|
||
border: selectedPermCount > 0 ? '1px solid #b7eb8f' : '1px solid #d9d9d9',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '4px'
|
||
}}
|
||
>
|
||
<i className={`ri-${isExpanded ? 'arrow-up-s' : 'arrow-down-s'}-line`}></i>
|
||
<span>API权限 ({selectedPermCount}/{permissions.length})</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* v3.0: 展开的API权限列表(过滤掉"所有权限"项) */}
|
||
{hasPermissions && isExpanded && (
|
||
<div
|
||
className="permissions-list"
|
||
style={{
|
||
marginTop: '8px',
|
||
marginLeft: '24px',
|
||
padding: '12px',
|
||
backgroundColor: '#fafafa',
|
||
borderRadius: '6px',
|
||
border: '1px solid #e8e8e8'
|
||
}}
|
||
>
|
||
{permissions.map(permission => (
|
||
<label
|
||
key={permission.id}
|
||
className="permission-item"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
padding: '6px 0',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedPermissionIds.includes(permission.id)}
|
||
onChange={(e) => handleTogglePermission(permission.id, e.target.checked)}
|
||
style={{ margin: 0 }}
|
||
disabled={!isProvincialAdmin}
|
||
/>
|
||
<span
|
||
style={{
|
||
...getMethodTagStyle(permission.api_method),
|
||
padding: '2px 6px',
|
||
borderRadius: '4px',
|
||
fontSize: '11px',
|
||
fontWeight: 600,
|
||
minWidth: '50px',
|
||
textAlign: 'center'
|
||
}}
|
||
>
|
||
{permission.api_method}
|
||
</span>
|
||
<span style={{ color: '#333', fontSize: '13px' }}>
|
||
{permission.display_name}
|
||
</span>
|
||
<span style={{ color: '#999', fontSize: '11px', marginLeft: 'auto' }}>
|
||
{permission.api_path}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{hasChildren && (
|
||
<div className="route-children">
|
||
{renderRouteTree(route.children!, level + 1)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="role-permissions-page">
|
||
<div className="loading-container">
|
||
<i className="ri-loader-4-line spin"></i>
|
||
<span>加载中...</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ==================== 权限检查移除说明 ====================
|
||
// 原有的客户端权限检查已移除,统一使用 root.tsx 的路由权限控制
|
||
// 如果用户能访问到这个页面,说明已经通过了路由权限检查
|
||
// 不再显示"无权限"提示页面
|
||
|
||
// 如果数据为空,可能是数据加载失败,显示友好的空状态提示
|
||
const hasNoData = roles.length === 0 && routes.length === 0 && users.length === 0 && !loading;
|
||
|
||
// 数据加载失败提示(不是权限问题)
|
||
if (hasNoData) {
|
||
return (
|
||
<div className="role-permissions-page">
|
||
<Card className="no-data-card">
|
||
<div className="empty-state" style={{ padding: '80px 40px' }}>
|
||
<i className="ri-database-2-line" style={{ fontSize: '96px', color: '#dcdfe6' }}></i>
|
||
<h2 style={{ fontSize: '24px', fontWeight: 600, color: '#303133', marginTop: '24px', marginBottom: '12px' }}>
|
||
暂无数据
|
||
</h2>
|
||
<p style={{ fontSize: '16px', color: '#606266', marginBottom: '32px' }}>
|
||
系统中暂无角色、路由或用户数据,请稍后重试或联系管理员
|
||
</p>
|
||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
|
||
<Button type="default" icon="ri-arrow-left-line" onClick={() => window.history.back()}>
|
||
返回上一页
|
||
</Button>
|
||
<Button type="primary" icon="ri-home-line" onClick={() => window.location.href = '/'}>
|
||
返回首页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="role-permissions-page">
|
||
{/* 页面头部 */}
|
||
<div className="page-header">
|
||
<h2 className="page-title">
|
||
<i className="ri-shield-user-line"></i>
|
||
角色权限管理
|
||
</h2>
|
||
<div className="page-actions">
|
||
<Button
|
||
type="primary"
|
||
icon="ri-add-line"
|
||
onClick={() => setShowCreateModal(true)}
|
||
>
|
||
新建角色
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="permissions-container">
|
||
{/* 左侧:角色列表 */}
|
||
<Card className="roles-panel" title="角色列表" bodyClassName="p-0">
|
||
<div className="roles-list">
|
||
{roles.map(role => (
|
||
<div
|
||
key={role.id}
|
||
className={`role-item ${selectedRole?.id === role.id ? 'active' : ''}`}
|
||
onClick={() => handleSelectRole(role)}
|
||
>
|
||
<div className="role-info">
|
||
<div className="role-header">
|
||
<span className="role-name">{role.role_name}</span>
|
||
{role.is_system_role && (
|
||
<span className="system-badge">系统角色</span>
|
||
)}
|
||
</div>
|
||
<div className="role-key">{role.role_key}</div>
|
||
<div className="role-desc">{role.description}</div>
|
||
<div className="role-meta">
|
||
<span className="data-scope">
|
||
<i className="ri-database-line"></i>
|
||
{getDataScopeLabel(role.data_scope)}
|
||
</span>
|
||
<span className="priority">
|
||
<i className="ri-sort-asc"></i>
|
||
优先级: {role.priority}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{!role.is_system_role && (
|
||
<div className="role-actions">
|
||
<button
|
||
className="btn-icon"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleEditRole(role);
|
||
}}
|
||
title="编辑"
|
||
>
|
||
<i className="ri-edit-line"></i>
|
||
</button>
|
||
<button
|
||
className="btn-icon text-error"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteRole(role);
|
||
}}
|
||
title="删除"
|
||
>
|
||
<i className="ri-delete-bin-line"></i>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 右侧:角色详情和权限设置 */}
|
||
<div className="permissions-detail">
|
||
{selectedRole ? (
|
||
<>
|
||
{/* Tab 切换 */}
|
||
<Card className="tabs-card">
|
||
<div className="tabs-header">
|
||
<button
|
||
className={`tab-btn ${activeTab === 'permissions' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('permissions')}
|
||
>
|
||
<i className="ri-shield-check-line"></i>
|
||
路由权限
|
||
</button>
|
||
<button
|
||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('users')}
|
||
>
|
||
<i className="ri-team-line"></i>
|
||
用户列表 ({roleUsers.length})
|
||
</button>
|
||
</div>
|
||
|
||
<div className="tabs-content">
|
||
{/* 路由权限Tab */}
|
||
{activeTab === 'permissions' && (
|
||
<div className="permissions-tab">
|
||
{/* v3.3: 权限提示(仅省级管理员可修改) */}
|
||
{!isProvincialAdmin && (
|
||
<div className="form-notice warning" style={{ marginBottom: '16px' }}>
|
||
<i className="ri-information-line"></i>
|
||
<span>您当前为只读模式,仅省级管理员可以修改角色路由权限</span>
|
||
</div>
|
||
)}
|
||
<div className="permissions-header">
|
||
<h3>为角色 "{selectedRole.role_name}" 分配路由权限</h3>
|
||
<Button
|
||
type="primary"
|
||
icon="ri-save-line"
|
||
onClick={handleSavePermissions}
|
||
disabled={!isProvincialAdmin}
|
||
>
|
||
保存权限
|
||
</Button>
|
||
</div>
|
||
|
||
{/* v3.0: 始终使用 routes 渲染所有可用路由,permissions 从 routePermissionsMap 获取 */}
|
||
<div className="routes-tree">
|
||
{renderRouteTree(routes)}
|
||
</div>
|
||
|
||
{/* v3.0: 更新权限统计,显示路由和API权限数量 */}
|
||
<div className="permissions-summary">
|
||
<i className="ri-information-line"></i>
|
||
已选择 <strong>{selectedRouteIds.length}</strong> 个路由权限
|
||
{selectedPermissionIds.length > 0 && (
|
||
<>
|
||
,<strong>{selectedPermissionIds.length}</strong> 个API权限
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 用户列表Tab */}
|
||
{activeTab === 'users' && (
|
||
<div className="users-tab">
|
||
<div className="users-header">
|
||
<h3>拥有角色 "{selectedRole.role_name}" 的用户</h3>
|
||
<Button
|
||
type="primary"
|
||
icon="ri-user-add-line"
|
||
onClick={() => setShowAssignUserModal(true)}
|
||
>
|
||
分配用户
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="users-list">
|
||
{roleUsers.length > 0 ? (
|
||
roleUsers.map(user => (
|
||
<div key={user.id} className="user-card">
|
||
<div className="user-avatar">
|
||
<i className="ri-user-line"></i>
|
||
</div>
|
||
<div className="user-info">
|
||
<div className="user-name">
|
||
{user.nick_name}
|
||
{user.is_leader && (
|
||
<span className="leader-badge">负责人</span>
|
||
)}
|
||
</div>
|
||
<div className="user-username">@{user.username}</div>
|
||
<div className="user-org">{user.ou_name}</div>
|
||
<div className="user-contact">
|
||
{user.phone_number && (
|
||
<span>
|
||
<i className="ri-phone-line"></i>
|
||
{user.phone_number}
|
||
</span>
|
||
)}
|
||
{user.email && (
|
||
<span>
|
||
<i className="ri-mail-line"></i>
|
||
{user.email}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="user-actions">
|
||
<button
|
||
className="btn-icon text-error"
|
||
onClick={() => handleRemoveUserRole(user)}
|
||
title="移除角色"
|
||
>
|
||
<i className="ri-user-unfollow-line"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="empty-state">
|
||
<i className="ri-user-line"></i>
|
||
<p>暂无用户拥有此角色</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</>
|
||
) : (
|
||
<Card>
|
||
<div className="empty-state">
|
||
<i className="ri-shield-line"></i>
|
||
<p>请选择一个角色查看详情</p>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 创建角色模态框 */}
|
||
<CreateRoleModal
|
||
isOpen={showCreateModal}
|
||
onClose={() => setShowCreateModal(false)}
|
||
onSuccess={loadData}
|
||
/>
|
||
|
||
{/* 编辑角色模态框 */}
|
||
<EditRoleModal
|
||
isOpen={showEditModal}
|
||
onClose={() => {
|
||
setShowEditModal(false);
|
||
setRoleToEdit(null);
|
||
}}
|
||
onSuccess={loadData}
|
||
role={roleToEdit}
|
||
/>
|
||
|
||
{/* 分配用户模态框 */}
|
||
<AssignUserModal
|
||
isOpen={showAssignUserModal}
|
||
onClose={() => setShowAssignUserModal(false)}
|
||
onSuccess={async () => {
|
||
// 重新加载该角色的用户列表
|
||
if (selectedRole) {
|
||
const users = await getRoleUsers(selectedRole.id);
|
||
setRoleUsers(users);
|
||
}
|
||
}}
|
||
role={selectedRole}
|
||
isCityAdmin={isCityAdmin}
|
||
currentUserArea={currentUserArea}
|
||
/>
|
||
|
||
{/* 确认删除模态框 */}
|
||
<Modal
|
||
isOpen={showDeleteConfirm}
|
||
onClose={() => {
|
||
setShowDeleteConfirm(false);
|
||
setDeleteTarget(null);
|
||
}}
|
||
title={deleteTarget?.type === 'role' ? '确认删除角色' : '确认移除用户角色'}
|
||
>
|
||
<div style={{ padding: '20px 0' }}>
|
||
{deleteTarget?.type === 'role' && deleteTarget.role && (
|
||
<div>
|
||
<p style={{ marginBottom: '16px', fontSize: '15px', lineHeight: '1.6' }}>
|
||
您确定要删除角色 <strong>"{deleteTarget.role.role_name}"</strong> 吗?
|
||
</p>
|
||
{deleteTarget.userCount !== undefined && deleteTarget.userCount > 0 && (
|
||
<p style={{
|
||
marginBottom: '16px',
|
||
color: '#ff6b00',
|
||
fontSize: '14px',
|
||
padding: '12px',
|
||
backgroundColor: '#fff7e6',
|
||
borderLeft: '3px solid #ff6b00',
|
||
borderRadius: '4px'
|
||
}}>
|
||
⚠️ 该角色当前关联了 <strong>{deleteTarget.userCount}</strong> 个用户,删除后将自动解除这些用户的角色绑定。
|
||
</p>
|
||
)}
|
||
<p style={{ marginBottom: '16px', color: '#666', fontSize: '14px' }}>
|
||
此操作将永久删除该角色、相关的权限配置和路由分配,且无法恢复。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{deleteTarget?.type === 'userRole' && deleteTarget.role && deleteTarget.user && (
|
||
<div>
|
||
<p style={{ marginBottom: '16px', fontSize: '15px', lineHeight: '1.6' }}>
|
||
您确定要移除用户 <strong>"{deleteTarget.user.nick_name}"</strong> 的角色 <strong>"{deleteTarget.role.role_name}"</strong> 吗?
|
||
</p>
|
||
<p style={{ marginBottom: '16px', color: '#666', fontSize: '14px' }}>
|
||
移除后,该用户将失去此角色的所有权限。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: '12px',
|
||
justifyContent: 'flex-end',
|
||
marginTop: '24px'
|
||
}}>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setShowDeleteConfirm(false);
|
||
setDeleteTarget(null);
|
||
}}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => {
|
||
if (deleteTarget?.type === 'role') {
|
||
confirmDeleteRole();
|
||
} else if (deleteTarget?.type === 'userRole') {
|
||
confirmRemoveUserRole();
|
||
}
|
||
}}
|
||
disabled={deleteCountdown > 0}
|
||
>
|
||
{deleteCountdown > 0 ? `确认删除 (${deleteCountdown}秒)` : '确认删除'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 权限警告模态框 */}
|
||
<Modal
|
||
isOpen={showPermissionWarning}
|
||
onClose={() => {
|
||
setShowPermissionWarning(false);
|
||
setPendingRouteChange(null);
|
||
}}
|
||
title="⚠️ 警告:取消角色权限管理路由"
|
||
size="medium"
|
||
>
|
||
<div style={{ padding: '20px 0' }}>
|
||
<p style={{ marginBottom: '16px', fontSize: '15px', lineHeight: '1.6', color: '#ff6b00' }}>
|
||
您正在尝试取消勾选 <strong>"/role-permissions"</strong> 路由权限。
|
||
</p>
|
||
<p style={{ marginBottom: '16px', fontSize: '14px', lineHeight: '1.6' }}>
|
||
<strong>请注意:</strong>如果取消此路由权限,该角色的用户将无法访问角色权限管理页面,这可能导致无法管理系统权限。
|
||
</p>
|
||
<p style={{ marginBottom: '16px', fontSize: '14px', color: '#666' }}>
|
||
请谨慎操作,确认后需要点击"保存权限"才会生效。
|
||
</p>
|
||
|
||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setShowPermissionWarning(false);
|
||
setPendingRouteChange(null);
|
||
}}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
onClick={confirmRemovePermissionRoute}
|
||
>
|
||
确认取消勾选
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|