feat(rbac): 按钮级权限控制(v-permission + 权限 store + 用户页导出)

- 新增 permission store + v-permission 指令(响应式,异步权限加载后可恢复)
- ty-crud 支持 createPermission 与动作 permission 过滤
- 用户页:分配角色/重置密码按权限码显示,新增导出按钮 + CSV 导出

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 16:19:53 +08:00
parent 7ae220581a
commit 2f7f83b8b1
11 changed files with 214 additions and 4 deletions

View File

@ -5,3 +5,8 @@ import type { ResourceNode } from '@/types/resource'
export function fetchMenus(): Promise<ResourceNode[]> {
return http.get<ResourceNode[]>('/auth/menus')
}
/** 当前用户权限码与超管标志(供按钮/路由级权限控制)。 */
export function fetchPermissions(): Promise<{ super: boolean; permissions: string[] }> {
return http.get<{ super: boolean; permissions: string[] }>('/auth/permissions')
}

View File

@ -71,3 +71,13 @@ export function deleteUser(id: string): Promise<unknown> {
export function resetPassword(id: string, password: string): Promise<unknown> {
return http.patch(`/users/${id}/password`, { password })
}
/** 用户已分配的角色 code 列表。 */
export function listUserRoles(userId: string, signal?: AbortSignal): Promise<string[]> {
return http.get<string[]>(`/users/${userId}/roles`, { signal })
}
/** 分配角色(整体替换)。 */
export function assignUserRoles(userId: string, roleCodes: string[]): Promise<unknown> {
return http.put(`/users/${userId}/roles`, { roleCodes })
}

View File

@ -12,7 +12,9 @@
<el-card class="ty-crud__table" shadow="never">
<div class="ty-crud__toolbar">
<div class="ty-crud__toolbar-left">
<el-button type="primary" @click="openCreate">新增{{ entityName }}</el-button>
<el-button v-if="!createPermission || permissionStore.has(createPermission)" type="primary" @click="openCreate">
新增{{ entityName }}
</el-button>
<slot name="toolbar" />
</div>
<div class="ty-crud__toolbar-right">
@ -80,9 +82,12 @@ import type {
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import { usePermissionStore } from '@/stores/modules/permission'
defineOptions({ name: 'TyCrud' })
const permissionStore = usePermissionStore()
const props = withDefaults(
defineProps<{
/** 实体名(用于标题与删除确认,如「用户」)。 */
@ -115,6 +120,8 @@ const props = withDefaults(
collapseCount?: number
/** 是否启用多选列(配合 #toolbar-right 做批量操作)。 */
selection?: boolean
/** 新增按钮所需权限码(无该权限且非超管时隐藏新增按钮)。 */
createPermission?: string
actionsWidth?: number | string
}>(),
{
@ -229,7 +236,11 @@ const resolvedActions = computed<CrudActionItem[]>(() =>
)
function visibleActions(row: Record<string, any>): CrudActionItem[] {
return resolvedActions.value.filter((a) => !a.show || a.show(row))
return resolvedActions.value.filter((a) => {
if (a.show && !a.show(row)) return false
if (a.permission && !permissionStore.has(a.permission)) return false
return true
})
}
function displayActions(row: Record<string, any>): CrudActionItem[] {

View File

@ -0,0 +1,25 @@
import { watchEffect, type Directive } from 'vue'
import { usePermissionStore } from '@/stores/modules/permission'
const stops = new WeakMap<HTMLElement, () => void>()
/**
* v-permission 指令:当前用户不具备指定权限码(且非超管)时隐藏元素。
* 用法:v-permission="'user:create'" 或 v-permission="['user:create', 'user:update']"(任一满足即可)。
* 权限异步加载:用 watchEffect 响应式隐藏/恢复,权限加载完成后自动显示。
*/
export const vPermission: Directive<HTMLElement, string | string[]> = {
mounted(el, binding) {
const store = usePermissionStore()
const required = (Array.isArray(binding.value) ? binding.value : [binding.value]).filter(Boolean) as string[]
if (required.length === 0) return
const stop = watchEffect(() => {
el.style.display = store.hasAny(required) ? '' : 'none'
})
stops.set(el, stop)
},
unmounted(el) {
stops.get(el)?.()
stops.delete(el)
},
}

View File

@ -74,6 +74,7 @@ import { HOME_PATH, useTabsStore } from '@/stores/modules/tabs'
import { useAuthStore } from '@/stores/modules/auth'
import { useMenuStore } from '@/stores/modules/menu'
import { useDictStore } from '@/stores/modules/dict'
import { usePermissionStore } from '@/stores/modules/permission'
import logo from '@/assets/images/logo.png'
import type { LayoutSettings } from '@/config/layout'
import type { ResourceNode } from '@/types/resource'
@ -85,6 +86,7 @@ const tabsStore = useTabsStore()
const auth = useAuthStore()
const menuStore = useMenuStore()
const dictStore = useDictStore()
const permissionStore = usePermissionStore()
const route = useRoute()
const router = useRouter()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
@ -94,10 +96,11 @@ const HEADER_HEIGHT = 56
const menus = computed(() => menuStore.menus)
const settingsOpen = ref(false)
// 登录后拉取菜单与字典
// 登录后拉取菜单、字典与权限码
onMounted(() => {
menuStore.fetch()
dictStore.fetch()
permissionStore.fetch()
})
const activePath = computed(() => route.path)
@ -223,6 +226,7 @@ async function handleLogout(): Promise<void> {
await auth.logout()
menuStore.reset()
dictStore.reset()
permissionStore.reset()
tabsStore.removeAll()
router.push('/login')
}

View File

@ -10,10 +10,14 @@ import { installI18n } from '@/i18n'
import { setAuthExpiredHandler } from '@/api/http'
import { useMenuStore } from '@/stores/modules/menu'
import { useDictStore } from '@/stores/modules/dict'
import { usePermissionStore } from '@/stores/modules/permission'
import { useTabsStore } from '@/stores/modules/tabs'
import { vPermission } from '@/directives/permission'
const app = createApp(App)
app.directive('permission', vPermission)
installRouter(app)
installStore(app)
@ -21,6 +25,7 @@ installStore(app)
setAuthExpiredHandler(() => {
useMenuStore(pinia).reset()
useDictStore(pinia).reset()
usePermissionStore(pinia).reset()
useTabsStore(pinia).removeAll()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')

View File

@ -0,0 +1,55 @@
<template>
<div v-loading="!ready" class="assign-roles">
<el-checkbox-group v-model="model.roleCodes" class="assign-roles__group">
<el-checkbox v-for="r in roles" :key="r.code" :value="r.code">
<span>{{ r.name }}</span>
<span class="assign-roles__code">{{ r.code }}</span>
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="ready && roles.length === 0" description="暂无可用角色" :image-size="60" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useRequest } from '@/composables/useRequest'
import { listRoles } from '@/api/role'
import { listUserRoles } from '@/api/user'
defineOptions({ name: 'AssignRolesForm' })
const props = defineProps<{ userId: string }>()
const { data: rolePage } = useRequest((signal: AbortSignal) => listRoles({ page: 1, pageSize: 1000 }, signal))
const { data: assigned } = useRequest((signal: AbortSignal) => listUserRoles(props.userId, signal))
const roles = computed(() => rolePage.value?.list ?? [])
const ready = computed(() => rolePage.value !== undefined && assigned.value !== undefined)
const model = reactive<{ roleCodes: string[] }>({ roleCodes: [] })
// 回显已分配角色
watch(assigned, (v) => {
if (v) model.roleCodes = [...v]
})
function submit(): { roleCodes: string[] } {
return { roleCodes: model.roleCodes }
}
defineExpose({ submit })
</script>
<style scoped lang="scss">
.assign-roles {
@apply min-h-[120px];
}
.assign-roles__group {
@apply flex flex-col gap-2;
}
.assign-roles__code {
@apply ml-1 text-xs text-text-secondary;
}
</style>

View File

@ -11,10 +11,14 @@
:update="update"
:remove="remove"
:actions="actions"
create-permission="user:create"
id-key="id"
name-key="username"
:edit-readonly-keys="['username']"
>
<template #toolbar>
<el-button v-permission="'user:export'" @click="exportUsers">导出</el-button>
</template>
<template #toolbar-right="{ selectedRows }">
<el-button
type="danger"
@ -34,6 +38,7 @@ import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/cru
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import {
assignUserRoles,
createUser,
deleteUser,
listUsers,
@ -44,6 +49,7 @@ import {
} from '@/api/user'
import { listOrgs } from '@/api/org'
import ResetPasswordForm from './reset-password-form.vue'
import AssignRolesForm from './assign-roles-form.vue'
// 组织列表(下拉 + 名称回显)
const { data: orgs } = useRequest((signal: AbortSignal) => listOrgs(signal))
@ -112,7 +118,8 @@ const formSchema = computed<JsonSchema>(() => ({
const actions: CrudAction[] = [
'edit',
'delete',
{ label: '重置密码', type: 'warning', onClick: (row) => resetPwd(row) },
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(row) },
]
function toListQuery(query: PageQuery): UserListQuery {
@ -144,6 +151,18 @@ async function resetPwd(row: Record<string, any>): Promise<void> {
ElMessage.success('密码已重置')
}
async function assignRolesAction(row: Record<string, any>): Promise<void> {
const result = await showDialog({
component: AssignRolesForm,
props: { userId: String(row.id) },
title: `分配角色(${row.username})`,
width: '480px',
})
if (result == null) return
await assignUserRoles(String(row.id), result.roleCodes)
ElMessage.success('角色已保存')
}
const crudRef = ref()
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
@ -154,4 +173,25 @@ async function batchDelete(rows: Record<string, any>[]): Promise<void> {
ElMessage.success('删除成功')
crudRef.value?.reload()
}
function escapeCsv(v: unknown): string {
const s = String(v ?? '')
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
async function exportUsers(): Promise<void> {
const result = await listUsers({ page: 1, pageSize: 1000 })
const headers = ['用户名', '昵称', '邮箱', '手机', '状态', '所属组织', '创建时间']
const lines = [headers.join(',')]
for (const u of result.list) {
lines.push([u.username, u.nickname, u.email, u.phone, u.status, u.orgCode, u.createdAt].map(escapeCsv).join(','))
}
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '用户列表.csv'
a.click()
URL.revokeObjectURL(url)
}
</script>

View File

@ -0,0 +1,40 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { fetchPermissions } from '@/api/auth'
/** 当前用户权限码与超管标志(登录后拉取,供 v-permission 指令使用,非持久化)。 */
export const usePermissionStore = defineStore('permissionStore', () => {
const permissions = ref<string[]>([])
const isSuper = ref(false)
const loaded = ref(false)
const loading = ref(false)
async function fetch(): Promise<void> {
if (loaded.value || loading.value) return
loading.value = true
try {
const data = await fetchPermissions()
permissions.value = data.permissions
isSuper.value = data.super
loaded.value = true
} finally {
loading.value = false
}
}
function has(code: string): boolean {
return isSuper.value || permissions.value.includes(code)
}
function hasAny(codes: string[]): boolean {
return isSuper.value || codes.some((code) => permissions.value.includes(code))
}
function reset(): void {
permissions.value = []
isSuper.value = false
loaded.value = false
}
return { permissions, isSuper, loaded, loading, fetch, has, hasAny, reset }
})

View File

@ -86,6 +86,8 @@ export interface CrudActionItem {
onClick: (row: Record<string, any>) => void | Promise<void>
/** 是否对当前行显示(缺省显示)。 */
show?: (row: Record<string, any>) => boolean
/** 所需权限码(无该权限且非超管时隐藏)。 */
permission?: string
}
/** 操作列动作:内置 'edit'/'delete' 或自定义动作。 */

13
typed-router.d.ts vendored
View File

@ -135,6 +135,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/system/user/assign-roles-form': RouteRecordInfo<
'/system/user/assign-roles-form',
'/system/user/assign-roles-form',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/user/reset-password-form': RouteRecordInfo<
'/system/user/reset-password-form',
'/system/user/reset-password-form',
@ -251,6 +258,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/system/user/assign-roles-form.vue': {
routes:
| '/system/user/assign-roles-form'
views:
| never
}
'src/pages/system/user/reset-password-form.vue': {
routes:
| '/system/user/reset-password-form'