feat(role): 添加角色用户绑定功能并完善权限验证
- 扩展前端HTTP拦截器支持403状态码重定向到登录页带超时标识 - 添加角色绑定和解绑用户的API接口函数 - 在JWT认证过滤器中增加令牌过期和无效令牌的异常处理 - 登录页面添加URL参数检测显示登录超时警告信息 - 角色管理页面添加用户绑定功能和用户选择弹窗界面 - 实现后端角色控制器的角色用户绑定和解绑接口逻辑 - 更新项目设计文档说明后续内容变更同步要求master
parent
5fe3b53680
commit
26c2b977d6
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 1. 项目概述
|
||||
本项目为“智能会议语音识别与总结系统”的管理后台,提供用户、角色、权限、设备与任务等管理能力。
|
||||
后端为 Java 服务,前端为后台管理 Web。
|
||||
后端为 Java 服务,前端为后台管理 Web。 后续所有内容变更都必须同步更新该文档
|
||||
|
||||
## 2. 技术栈
|
||||
### 后端
|
||||
|
|
|
|||
|
|
@ -116,6 +116,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (io.jsonwebtoken.ExpiredJwtException e) {
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Token expired\"}");
|
||||
return;
|
||||
} catch (io.jsonwebtoken.JwtException e) {
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Invalid token\"}");
|
||||
return;
|
||||
} catch (Exception ignored) {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import com.imeeting.common.annotation.Log;
|
|||
import com.imeeting.entity.SysRole;
|
||||
import com.imeeting.entity.SysRolePermission;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import com.imeeting.entity.SysUserRole;
|
||||
import com.imeeting.mapper.SysRolePermissionMapper;
|
||||
import com.imeeting.mapper.SysUserRoleMapper;
|
||||
import com.imeeting.service.SysRoleService;
|
||||
import com.imeeting.service.SysUserService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
|
@ -21,11 +23,13 @@ public class RoleController {
|
|||
private final SysRoleService sysRoleService;
|
||||
private final SysUserService sysUserService;
|
||||
private final SysRolePermissionMapper sysRolePermissionMapper;
|
||||
private final SysUserRoleMapper sysUserRoleMapper;
|
||||
|
||||
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper) {
|
||||
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper) {
|
||||
this.sysRoleService = sysRoleService;
|
||||
this.sysUserService = sysUserService;
|
||||
this.sysRolePermissionMapper = sysRolePermissionMapper;
|
||||
this.sysUserRoleMapper = sysUserRoleMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -103,6 +107,42 @@ public class RoleController {
|
|||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/users")
|
||||
@PreAuthorize("@ss.hasPermi('sys_role:update')")
|
||||
@Log(value = "角色关联用户", type = "角色管理")
|
||||
public ApiResponse<Boolean> bindUsers(@PathVariable Long id, @RequestBody UserBindingPayload payload) {
|
||||
if (payload == null || payload.getUserIds() == null) {
|
||||
return ApiResponse.ok(true);
|
||||
}
|
||||
for (Long userId : payload.getUserIds()) {
|
||||
QueryWrapper<SysUserRole> qw = new QueryWrapper<>();
|
||||
qw.eq("role_id", id).eq("user_id", userId);
|
||||
if (sysUserRoleMapper.selectCount(qw) == 0) {
|
||||
SysUserRole ur = new SysUserRole();
|
||||
ur.setRoleId(id);
|
||||
ur.setUserId(userId);
|
||||
sysUserRoleMapper.insert(ur);
|
||||
}
|
||||
}
|
||||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/users/{userId}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_role:update')")
|
||||
@Log(value = "角色取消关联用户", type = "角色管理")
|
||||
public ApiResponse<Boolean> unbindUser(@PathVariable Long id, @PathVariable Long userId) {
|
||||
QueryWrapper<SysUserRole> qw = new QueryWrapper<>();
|
||||
qw.eq("role_id", id).eq("user_id", userId);
|
||||
sysUserRoleMapper.delete(qw);
|
||||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
public static class UserBindingPayload {
|
||||
private List<Long> userIds;
|
||||
public List<Long> getUserIds() { return userIds; }
|
||||
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
|
||||
}
|
||||
|
||||
public static class PermissionBindingPayload {
|
||||
private List<Long> permIds;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ http.interceptors.response.use(
|
|||
return resp;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||
// Clear session/local storage
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
sessionStorage.removeItem("userProfile");
|
||||
// Force redirect to login
|
||||
window.location.href = "/login";
|
||||
// Force redirect to login with timeout flag
|
||||
window.location.href = "/login?timeout=1";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,16 @@ export async function fetchUsersByRoleId(roleId: number) {
|
|||
return resp.data.data as SysUser[];
|
||||
}
|
||||
|
||||
export async function bindUsersToRole(roleId: number, userIds: number[]) {
|
||||
const resp = await http.post(`/api/roles/${roleId}/users`, { userIds });
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function unbindUserFromRole(roleId: number, userId: number) {
|
||||
const resp = await http.delete(`/api/roles/${roleId}/users/${userId}`);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function fetchLogs(params: any) {
|
||||
const resp = await http.get("/api/logs", { params });
|
||||
return resp.data.data;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ export default function Login() {
|
|||
init();
|
||||
}, [loadCaptcha]);
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get("timeout") === "1") {
|
||||
message.warning(t('login.loginTimeout'));
|
||||
// Clean up the URL to avoid repeated messages on refresh
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import {
|
|||
Col,
|
||||
Tabs,
|
||||
Empty,
|
||||
Select
|
||||
Select,
|
||||
Modal
|
||||
} from "antd";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
|
@ -28,7 +29,10 @@ import {
|
|||
saveRolePermissions,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
fetchUsersByRoleId
|
||||
fetchUsersByRoleId,
|
||||
bindUsersToRole,
|
||||
unbindUserFromRole,
|
||||
listUsers
|
||||
} from "../api";
|
||||
import type { SysPermission, SysRole, SysUser } from "../types";
|
||||
import { usePermission } from "../hooks/usePermission";
|
||||
|
|
@ -40,7 +44,8 @@ import {
|
|||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
UserOutlined,
|
||||
SaveOutlined
|
||||
SaveOutlined,
|
||||
UserAddOutlined
|
||||
} from "@ant-design/icons";
|
||||
import "./Roles.css";
|
||||
|
||||
|
|
@ -107,6 +112,12 @@ export default function Roles() {
|
|||
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
|
||||
// User selection states
|
||||
const [allUsers, setAllUsers] = useState<SysUser[]>([]);
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [selectedUserKeys, setSelectedUserKeys] = useState<number[]>([]);
|
||||
const [userSearchText, setUserSearchText] = useState("");
|
||||
|
||||
// Search
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
|
|
@ -122,6 +133,53 @@ export default function Roles() {
|
|||
[permissions, t]
|
||||
);
|
||||
|
||||
const loadAllUsers = async () => {
|
||||
try {
|
||||
const list = await listUsers();
|
||||
setAllUsers(list || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const openUserModal = () => {
|
||||
loadAllUsers();
|
||||
setSelectedUserKeys([]);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAddUsers = async () => {
|
||||
if (!selectedRole || selectedUserKeys.length === 0) return;
|
||||
try {
|
||||
await bindUsersToRole(selectedRole.roleId, selectedUserKeys);
|
||||
message.success(t('common.success'));
|
||||
setUserModalOpen(false);
|
||||
selectRole(selectedRole);
|
||||
} catch (e) {
|
||||
message.error(t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbindUser = async (userId: number) => {
|
||||
if (!selectedRole) return;
|
||||
try {
|
||||
await unbindUserFromRole(selectedRole.roleId, userId);
|
||||
message.success(t('common.success'));
|
||||
selectRole(selectedRole);
|
||||
} catch (e) {
|
||||
message.error(t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredModalUsers = useMemo(() => {
|
||||
const existingIds = new Set(roleUsers.map(u => u.userId));
|
||||
return allUsers.filter(u =>
|
||||
!existingIds.has(u.userId) &&
|
||||
(u.username.toLowerCase().includes(userSearchText.toLowerCase()) ||
|
||||
u.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
|
||||
);
|
||||
}, [allUsers, roleUsers, userSearchText]);
|
||||
|
||||
const loadPermissions = async () => {
|
||||
try {
|
||||
const list = await listPermissions();
|
||||
|
|
@ -400,6 +458,16 @@ export default function Roles() {
|
|||
tab={<Space><UserOutlined aria-hidden="true" />{t('roles.assignedUsers')} ({roleUsers.length})</Space>}
|
||||
key="users"
|
||||
>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UserAddOutlined aria-hidden="true" />}
|
||||
onClick={openUserModal}
|
||||
disabled={!can("sys_role:update")}
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
|
|
@ -426,6 +494,26 @@ export default function Roles() {
|
|||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('common.action'),
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={t('common.delete') + "?"}
|
||||
onConfirm={() => handleUnbindUser(record.userId)}
|
||||
disabled={!can("sys_role:update")}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined aria-hidden="true" />}
|
||||
disabled={!can("sys_role:update")}
|
||||
/>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
|
@ -440,6 +528,41 @@ export default function Roles() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* User Selection Modal */}
|
||||
<Modal
|
||||
title={t('roles.assignedUsers')}
|
||||
open={userModalOpen}
|
||||
onCancel={() => setUserModalOpen(false)}
|
||||
onOk={handleAddUsers}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder={t('users.searchPlaceholder')}
|
||||
prefix={<SearchOutlined aria-hidden="true" />}
|
||||
value={userSearchText}
|
||||
onChange={e => setUserSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
dataSource={filteredModalUsers}
|
||||
pagination={{ pageSize: 5 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedUserKeys,
|
||||
onChange: (keys) => setSelectedUserKeys(keys as number[])
|
||||
}}
|
||||
columns={[
|
||||
{ title: t('users.displayName'), dataIndex: 'displayName' },
|
||||
{ title: t('users.username'), dataIndex: 'username' },
|
||||
{ title: t('users.org'), dataIndex: 'orgId', render: () => '-' } // Simplification
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Basic Info Drawer */}
|
||||
<Drawer
|
||||
title={editing ? t('roles.drawerTitleEdit') : t('roles.drawerTitleCreate')}
|
||||
|
|
|
|||
Loading…
Reference in New Issue