feat(role): 添加角色菜单分配功能

- 新增 RoleAssignMenu 结构体用于角色菜单分配参数
- 实现 assign_menu 和 get_role_menu_ids 后端命令接口
- 在前端角色管理页面添加分配菜单弹窗功能
- 集成菜单树形结构展示和多选分配逻辑
- 更新用户角色分配接口为批量分配模式
- 优化表格容器滚动样式为水平自适应
This commit is contained in:
Yuhang Wu 2026-07-30 14:38:52 +08:00
parent 27341c8a53
commit 0ae78b2d6f
7 changed files with 189 additions and 45 deletions

View File

@ -57,6 +57,12 @@ pub struct RoleSaveReq {
pub status: bool, pub status: bool,
} }
#[derive(Debug, Deserialize)]
pub struct RoleAssignMenu {
pub role_id: i64,
pub menu_ids: Vec<i64>,
}
#[tauri::command] #[tauri::command]
#[auto_collect_command] #[auto_collect_command]
pub async fn role_save( pub async fn role_save(
@ -72,14 +78,15 @@ pub async fn role_save(
if exist > 0 { if exist > 0 {
return Ok(ApiResponse::error(2001, "角色_key已存在")); return Ok(ApiResponse::error(2001, "角色_key已存在"));
} }
let result = sqlx::query("INSERT INTO sys_role (role_key, role_name, sort, status) VALUES (?, ?, ?, ?)") let result =
.bind(&params.role_key) sqlx::query("INSERT INTO sys_role (role_key, role_name, sort, status) VALUES (?, ?, ?, ?)")
.bind(&params.role_name) .bind(&params.role_key)
.bind(&params.sort) .bind(&params.role_name)
.bind(&params.status) .bind(&params.sort)
.execute(&*pool) .bind(&params.status)
.await .execute(&*pool)
.map_err(|e| e.to_string())?; .await
.map_err(|e| e.to_string())?;
let id = result.last_insert_id() as i64; let id = result.last_insert_id() as i64;
Ok(ApiResponse::success(id)) Ok(ApiResponse::success(id))
} }
@ -181,15 +188,17 @@ pub async fn role_update(
return Ok(ApiResponse::error(2001, "角色_key已存在")); return Ok(ApiResponse::error(2001, "角色_key已存在"));
} }
sqlx::query("UPDATE sys_role SET role_key = ?, role_name = ?, sort = ?, status = ? WHERE id = ?") sqlx::query(
.bind(&params.role_key) "UPDATE sys_role SET role_key = ?, role_name = ?, sort = ?, status = ? WHERE id = ?",
.bind(&params.role_name) )
.bind(&params.sort) .bind(&params.role_key)
.bind(&params.status) .bind(&params.role_name)
.bind(&params.id) .bind(&params.sort)
.execute(&*pool) .bind(&params.status)
.await .bind(&params.id)
.map_err(|e| e.to_string())?; .execute(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(())) Ok(ApiResponse::success(()))
} }
@ -214,3 +223,46 @@ pub async fn role_list(pool: State<'_, MySqlPool>) -> Result<ApiResponse<Vec<Rol
let role_list = roles.into_iter().map(|role| RoleInfo::from(role)).collect(); let role_list = roles.into_iter().map(|role| RoleInfo::from(role)).collect();
Ok(ApiResponse::success(role_list)) Ok(ApiResponse::success(role_list))
} }
#[tauri::command]
#[auto_collect_command]
pub async fn assign_menu(
pool: State<'_, MySqlPool>,
params: RoleAssignMenu,
) -> Result<ApiResponse<()>, String> {
sqlx::query("DELETE FROM sys_role_menu WHERE role_id = ?")
.bind(params.role_id)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
if params.menu_ids.is_empty() {
return Ok(ApiResponse::<()>::success_empty());
}
let mut query_builder = QueryBuilder::new("INSERT INTO sys_role_menu (role_id, menu_id)");
query_builder.push_values(&params.menu_ids, |mut b, menu_id| {
b.push_bind(params.role_id).push_bind(menu_id);
});
let _ = query_builder
.build()
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::<()>::success_empty())
}
#[tauri::command]
#[auto_collect_command]
pub async fn get_role_menu_ids(
pool: State<'_, MySqlPool>,
role_id: i64,
) -> Result<ApiResponse<Vec<i64>>, String> {
let ids: Vec<i64> =
sqlx::query_scalar::<_, i64>("SELECT menu_id FROM sys_role_menu WHERE role_id = ?")
.bind(role_id)
.fetch_all(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(ids))
}

View File

@ -71,7 +71,7 @@ impl From<SysUser> for UserInfo {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UserAssignRole { pub struct UserAssignRole {
pub user_id: i64, pub user_id: i64,
pub role_id: i64, pub role_ids: Vec<i64>,
} }
#[tauri::command] #[tauri::command]
@ -264,23 +264,27 @@ pub async fn get_user_by_id(
#[auto_collect_command] #[auto_collect_command]
pub async fn assign_role( pub async fn assign_role(
pool: State<'_, MySqlPool>, pool: State<'_, MySqlPool>,
list: Vec<UserAssignRole>, params: UserAssignRole,
) -> Result<ApiResponse<()>, String> { ) -> Result<ApiResponse<()>, String> {
let user_id = match list.first() {
Some(first) => first.user_id,
None => return Ok(ApiResponse::<()>::success_empty()),
};
sqlx::query("DELETE FROM sys_user_role WHERE user_id = ?") sqlx::query("DELETE FROM sys_user_role WHERE user_id = ?")
.bind(user_id) .bind(params.user_id)
.execute(&*pool) .execute(&*pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if params.role_ids.is_empty() {
return Ok(ApiResponse::<()>::success_empty());
}
let mut query_builder = QueryBuilder::new("INSERT INTO sys_user_role (user_id, role_id)"); let mut query_builder = QueryBuilder::new("INSERT INTO sys_user_role (user_id, role_id)");
query_builder.push_values(&list, |mut b, item| { query_builder.push_values(&params.role_ids, |mut b, role_id| {
b.push_bind(item.user_id).push_bind(item.role_id); b.push_bind(params.user_id).push_bind(role_id);
}); });
let _ = query_builder.build().execute(&*pool).await.map_err(|e| e.to_string())?; let _ = query_builder
.build()
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::<()>::success_empty()) Ok(ApiResponse::<()>::success_empty())
} }

View File

@ -41,6 +41,14 @@ const RoleApi = {
}, },
roleList: async (): Promise<ApiResponse<RoleInfo[]>> => { roleList: async (): Promise<ApiResponse<RoleInfo[]>> => {
return await invoke("role_list") return await invoke("role_list")
},
// 给角色分配菜单
assignMenu: async (roleId: number, menuIds: number[]): Promise<ApiResponse<any>> => {
return await invoke("assign_menu", {params: {role_id: roleId, menu_ids: menuIds}})
},
// 获取角色菜单id
getRoleMenuIds: async (roleId: number): Promise<ApiResponse<number[]>> => {
return await invoke("get_role_menu_ids", {roleId})
} }
} }

View File

@ -33,11 +33,6 @@ interface UserSaveReq {
creater: number creater: number
} }
interface AssignRoleReq {
user_id: number
role_id: number
}
const UserApi = { const UserApi = {
// 登录 // 登录
login: async (userName: string, password: string): Promise<ApiResponse<UserInfo>> => { login: async (userName: string, password: string): Promise<ApiResponse<UserInfo>> => {
@ -64,8 +59,8 @@ const UserApi = {
return await invoke("get_user_by_id", {id}) return await invoke("get_user_by_id", {id})
}, },
// 给用户分配角色 // 给用户分配角色
assignRole: async (list: AssignRoleReq[]): Promise<ApiResponse<any>> => { assignRole: async (userId: number, roleIds: number[]): Promise<ApiResponse<any>> => {
return await invoke("assign_role", {list}) return await invoke("assign_role", {params: {user_id: userId, role_ids: roleIds}})
}, },
// 获取用户角色id // 获取用户角色id
getUserRole: async (userId: number): Promise<ApiResponse<number[]>> => { getUserRole: async (userId: number): Promise<ApiResponse<number[]>> => {
@ -74,4 +69,4 @@ const UserApi = {
} }
export default UserApi export default UserApi
export type {UserInfo, UserSaveReq, UserPageReq, AssignRoleReq} export type {UserInfo, UserSaveReq, UserPageReq}

View File

@ -360,7 +360,7 @@ function handleDelete(row: MenuInfo) {
background: var(--color-surface, #fff); background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #E5E6EB); border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow-x: auto;
} }
.table-card :deep(.n-data-table) { .table-card :deep(.n-data-table) {

View File

@ -63,6 +63,29 @@
</n-space> </n-space>
</template> </template>
</n-modal> </n-modal>
<!-- 分配菜单弹窗 -->
<n-modal v-model:show="showMenuModal" preset="card" :title="'分配菜单 - ' + selectedMenuRole.role_name" :style="{ width: '500px' }" :mask-closable="false">
<div v-if="menuList.length > 0" style="max-height: 420px; overflow-y: auto; padding: 4px 0;">
<n-checkbox-group v-model:value="checkedMenuIds">
<n-space vertical :size="0">
<div v-for="item in flatMenuList" :key="item.id" :style="{ paddingLeft: item._depth * 24 + 'px', paddingTop: '2px', paddingBottom: '2px' }">
<n-checkbox :value="item.id">
<span :style="{ fontWeight: item.children && item.children.length > 0 ? 600 : 400 }">{{ item.title }}</span>
<span style="color: #86909C; font-size: 12px; margin-left: 8px;">{{ item.name }}</span>
</n-checkbox>
</div>
</n-space>
</n-checkbox-group>
</div>
<n-empty v-else description="暂无菜单数据" style="padding: 24px 0;" />
<template #footer>
<n-space justify="end">
<n-button @click="showMenuModal = false">取消</n-button>
<n-button type="primary" :loading="menuSubmitting" @click="handleMenuSubmit">确定</n-button>
</n-space>
</template>
</n-modal>
</div> </div>
</template> </template>
@ -70,8 +93,9 @@
import { ref, reactive, computed, h } from 'vue' import { ref, reactive, computed, h } from 'vue'
import { NButton, NIcon, useMessage, useDialog } from 'naive-ui' import { NButton, NIcon, useMessage, useDialog } from 'naive-ui'
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui' import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
import { SearchOutline, AddOutline, CreateOutline, TrashOutline } from '@vicons/ionicons5' import { SearchOutline, AddOutline, CreateOutline, TrashOutline, AppsOutline } from '@vicons/ionicons5'
import RoleApi, { type RoleInfo, type RoleSaveReq } from '@/api/system/role' import RoleApi, { type RoleInfo, type RoleSaveReq } from '@/api/system/role'
import MenuApi, { type MenuInfo } from '@/api/system/menu'
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
@ -104,9 +128,13 @@ const columns: DataTableColumns<RoleInfo> = [
}, },
{ title: '创建时间', key: 'create_time', width: 170, render(row) { return row.create_time ?? '-' } }, { title: '创建时间', key: 'create_time', width: 170, render(row) { return row.create_time ?? '-' } },
{ {
title: '操作', key: 'actions', width: 150, align: 'center', title: '操作', key: 'actions', width: 220, align: 'center',
render(row) { render(row) {
return h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'center' } }, [ return h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'center' } }, [
h(NButton, {
text: true, size: 'tiny', type: 'info',
onClick: (e: Event) => { e.stopPropagation(); openMenuModal(row) },
}, { icon: () => h(NIcon, null, { default: () => h(AppsOutline) }), default: () => '菜单' }),
h(NButton, { h(NButton, {
text: true, size: 'tiny', type: 'primary', text: true, size: 'tiny', type: 'primary',
onClick: (e: Event) => { e.stopPropagation(); openEdit(row) }, onClick: (e: Event) => { e.stopPropagation(); openEdit(row) },
@ -226,6 +254,67 @@ function handleDelete(row: RoleInfo) {
}, },
}) })
} }
//
type FlatMenu = MenuInfo & { _depth: number }
const showMenuModal = ref(false)
const menuSubmitting = ref(false)
const menuList = ref<MenuInfo[]>([])
const flatMenuList = ref<FlatMenu[]>([])
const checkedMenuIds = ref<number[]>([])
const selectedMenuRole = ref<RoleInfo>({ id: 0, role_key: '', role_name: '', sort: 0, status: true })
function flattenMenuTree(menus: MenuInfo[], depth: number = 0): FlatMenu[] {
const result: FlatMenu[] = []
for (const menu of menus) {
result.push({ ...menu, _depth: depth })
if (menu.children && menu.children.length > 0) {
result.push(...flattenMenuTree(menu.children, depth + 1))
}
}
return result
}
async function openMenuModal(row: RoleInfo) {
selectedMenuRole.value = row
try {
const [menuRes, menuIdsRes] = await Promise.all([
MenuApi.menuList({}),
RoleApi.getRoleMenuIds(row.id),
])
if (menuRes.success && menuRes.data) {
menuList.value = menuRes.data
flatMenuList.value = flattenMenuTree(menuRes.data)
}
if (menuIdsRes.success && menuIdsRes.data) {
checkedMenuIds.value = menuIdsRes.data
} else {
checkedMenuIds.value = []
}
} catch (e: any) {
message.error(e?.toString() ?? '加载菜单信息失败')
return
}
showMenuModal.value = true
}
async function handleMenuSubmit() {
menuSubmitting.value = true
try {
const res = await RoleApi.assignMenu(selectedMenuRole.value.id, checkedMenuIds.value)
if (res.success) {
message.success('分配菜单成功')
showMenuModal.value = false
} else {
message.error(res.message)
}
} catch (e: any) {
message.error(e?.toString() ?? '分配菜单失败')
} finally {
menuSubmitting.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@ -263,7 +352,7 @@ function handleDelete(row: RoleInfo) {
background: var(--color-surface, #fff); background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #E5E6EB); border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow-x: auto;
} }
.table-card :deep(.n-data-table) { .table-card :deep(.n-data-table) {

View File

@ -381,11 +381,7 @@ async function openRoleModal(row: UserInfo) {
async function handleRoleSubmit() { async function handleRoleSubmit() {
roleSubmitting.value = true roleSubmitting.value = true
try { try {
const list = checkedRoleIds.value.map(role_id => ({ const res = await UserApi.assignRole(selectedRoleUser.value.id, checkedRoleIds.value)
user_id: selectedRoleUser.value.id,
role_id,
}))
const res = await UserApi.assignRole(list)
if (res.success) { if (res.success) {
message.success('分配角色成功') message.success('分配角色成功')
showRoleModal.value = false showRoleModal.value = false
@ -435,7 +431,7 @@ async function handleRoleSubmit() {
background: var(--color-surface, #fff); background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #E5E6EB); border: 1px solid var(--color-border, #E5E6EB);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow-x: auto;
} }
.table-card :deep(.n-data-table) { .table-card :deep(.n-data-table) {