feat(rbac-web): 前端页面级权限与「权限与角色」管理页

- 新增 rbac 的 api/types/mock,对接后端 catalog/roles/users;mock 模式与后端 PageCatalog 完全对齐
- EffectivePermissions 增加 allowedPages,auth store 新增 hasPage 判定
- 路由守卫按 route.name = 页面 Key 做页面级放行,无权时跳转到 scope 内首个可访问页面;AppShell 菜单按 hasPage 过滤
- 路由守卫 switchScope 失败自动重试一次;新增懒加载 chunk 失败整页自愈 onError,修复切页白屏
- PermissionGuard hidden 态改为友好空态提示;AuthRoleView 重写为完整的用户/角色/权限编辑界面
This commit is contained in:
zhaowei.huang
2026-05-29 23:51:57 +08:00
parent 37680a0ae7
commit 00cfcf2897
11 changed files with 976 additions and 106 deletions
@@ -0,0 +1,65 @@
import http from './http'
import type {
RbacRole, RbacUserView, RbacCatalog, SaveRolePayload, CreateUserPayload, UpdateUserPayload
} from '@/types/rbac'
import {
mockRbacCatalog, mockRbacRoles, mockRbacSaveRole, mockRbacDeleteRole,
mockRbacUsers, mockRbacCreateUser, mockRbacUpdateUser, mockRbacSetPassword, mockRbacDeleteUser
} from '@/mock/rbac'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
export async function fetchCatalog(): Promise<RbacCatalog> {
if (MOCK) return mockRbacCatalog()
const { data } = await http.get<RbacCatalog>('/rbac/catalog')
return data
}
export async function fetchRoles(): Promise<RbacRole[]> {
if (MOCK) return mockRbacRoles()
const { data } = await http.get<RbacRole[]>('/rbac/roles')
return data
}
export async function saveRole(payload: SaveRolePayload): Promise<RbacRole> {
if (MOCK) return mockRbacSaveRole(payload)
if (payload.id) {
const { data } = await http.put<RbacRole>(`/rbac/roles/${payload.id}`, payload)
return data
}
const { data } = await http.post<RbacRole>('/rbac/roles', payload)
return data
}
export async function deleteRole(id: string): Promise<void> {
if (MOCK) return mockRbacDeleteRole(id)
await http.delete(`/rbac/roles/${id}`)
}
export async function fetchUsers(): Promise<RbacUserView[]> {
if (MOCK) return mockRbacUsers()
const { data } = await http.get<RbacUserView[]>('/rbac/users')
return data
}
export async function createUser(payload: CreateUserPayload): Promise<RbacUserView> {
if (MOCK) return mockRbacCreateUser(payload)
const { data } = await http.post<RbacUserView>('/rbac/users', payload)
return data
}
export async function updateUser(id: string, payload: UpdateUserPayload): Promise<RbacUserView> {
if (MOCK) return mockRbacUpdateUser(id, payload)
const { data } = await http.put<RbacUserView>(`/rbac/users/${id}`, payload)
return data
}
export async function setUserPassword(id: string, password: string): Promise<void> {
if (MOCK) return mockRbacSetPassword(id, password)
await http.put(`/rbac/users/${id}/password`, { password })
}
export async function deleteUser(id: string): Promise<void> {
if (MOCK) return mockRbacDeleteUser(id)
await http.delete(`/rbac/users/${id}`)
}
@@ -6,7 +6,12 @@
<slot /> <slot />
<div class="pg-mask" /> <div class="pg-mask" />
</div> </div>
<!-- hidden: 不渲染任何内容 --> <el-empty v-else class="pg-hidden">
<template #description>
<p class="pg-hidden-title">当前账号无权查看此功能</p>
<p class="pg-hidden-hint">控件{{ widgetId }}若应为管理员可见请尝试顶部切换管理员/运营或刷新页面</p>
</template>
</el-empty>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -24,4 +29,7 @@ const visibility = computed(() => auth.widgetOf(props.widgetId))
position: absolute; inset: 0; background: rgba(255, 255, 255, 0.3); position: absolute; inset: 0; background: rgba(255, 255, 255, 0.3);
pointer-events: all; cursor: not-allowed; pointer-events: all; cursor: not-allowed;
} }
.pg-hidden { padding: 48px 16px; }
.pg-hidden-title { margin: 0 0 6px; font-size: 14px; color: var(--mg-text-light, #fff); }
.pg-hidden-hint { margin: 0; font-size: 12px; color: var(--mg-text-muted, rgba(255,255,255,.65)); }
</style> </style>
@@ -1,7 +1,7 @@
<template> <template>
<el-radio-group v-model="current" size="small" @change="onChange"> <el-radio-group :model-value="current" size="small" @change="onChange">
<el-radio-button label="Platform">管理员</el-radio-button> <el-radio-button value="Platform">管理员</el-radio-button>
<el-radio-button label="RCSMonitor">运营</el-radio-button> <el-radio-button value="RCSMonitor">运营</el-radio-button>
</el-radio-group> </el-radio-group>
</template> </template>
@@ -15,15 +15,20 @@ import type { Scope } from '@/types/auth'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
// 会话 45 AR-6switchScope 已变成异步(调后端 /auth/switch-scope)。 // 单向受控 + 仅 @change 触发:避免「v-model set 与 @change 各触发一次」导致的
// computed.set 不能 await,只能 fire-and-forget 并在 catch 里弹错。 // 双重 switchScope 请求 / 双重导航竞态(旧实现会让一次点击发起两次切换,
const current = computed<Scope>({ // 偶发造成 scope 与 effectivePermissions 不一致 → 配置页 PermissionGuard 误判隐藏)。
get: () => auth.scope ?? 'Platform', const current = computed<Scope>(() => auth.scope ?? 'Platform')
set: (v) => { void switchAndNavigate(v) }
})
let switching = false
async function onChange(v: string | number | boolean | undefined) { async function onChange(v: string | number | boolean | undefined) {
await switchAndNavigate(v as Scope) if (switching) return
switching = true
try {
await switchAndNavigate(v as Scope)
} finally {
switching = false
}
} }
async function switchAndNavigate(scope: Scope) { async function switchAndNavigate(scope: Scope) {
@@ -89,10 +89,9 @@
</el-header> </el-header>
<el-main class="main mg-content"> <el-main class="main mg-content">
<router-view v-slot="{ Component }"> <!-- 不用 mode="out-in"懒加载子路由切换时易出现离场后新组件未挂载 主区全白刷新才恢复 -->
<transition name="fade-up" mode="out-in"> <router-view v-slot="{ Component, route: rv }">
<component :is="Component" /> <component :is="Component" v-if="Component" :key="rv.fullPath" class="route-page" />
</transition>
</router-view> </router-view>
</el-main> </el-main>
@@ -137,53 +136,68 @@ const runModeLabel = computed(() => {
} }
}) })
interface MenuItem { path: string; label: string; icon?: unknown; children?: MenuItem[] } // key 即「权限页面 Key」(= vue-router route.name),用于按 auth.allowedPages 过滤菜单。
// 分组节点(无 key)只要还有可见子项就保留。
interface MenuItem { path: string; label: string; icon?: unknown; key?: string; children?: MenuItem[] }
const ADMIN_MENU: MenuItem[] = [ const ADMIN_MENU: MenuItem[] = [
{ path: '/admin/dashboard', label: '总览', icon: Histogram }, { path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard' },
{ path: '/admin/map-monitor', label: '地图监控', icon: MapLocation }, { path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor' },
{ {
path: '/admin/design', label: '设计与编排', icon: Tools, path: '/admin/design', label: '设计与编排', icon: Tools,
children: [ children: [
{ path: '/admin/map-editor', label: '地图编辑' }, { path: '/admin/map-editor', label: '地图编辑', key: 'admin-map-editor' },
{ path: '/admin/project-properties', label: '项目属性' }, { path: '/admin/project-properties', label: '项目属性', key: 'admin-project-properties' },
{ path: '/admin/tracks', label: '场景管理' }, { path: '/admin/tracks', label: '场景管理', key: 'admin-tracks' },
{ path: '/admin/cars', label: '车辆管理' }, { path: '/admin/cars', label: '车辆管理', key: 'admin-cars' },
{ path: '/admin/processes', label: '进程管理' }, { path: '/admin/processes', label: '进程管理', key: 'admin-processes' },
{ path: '/admin/scripts', label: '脚本管理' }, { path: '/admin/scripts', label: '脚本管理', key: 'admin-scripts' },
{ path: '/admin/missions', label: '任务编排' } { path: '/admin/missions', label: '任务编排', key: 'admin-missions' }
] ]
}, },
{ path: '/admin/playback', label: '调度回放', icon: Notebook }, { path: '/admin/playback', label: '调度回放', icon: Notebook, key: 'admin-playback' },
{ {
path: '/admin/config', label: '平台配置中心', icon: Setting, path: '/admin/config', label: '平台配置中心', icon: Setting,
children: [ children: [
{ path: '/admin/config/system', label: '系统级配置' }, { path: '/admin/config/system', label: '系统级配置', key: 'admin-config-system' },
{ path: '/admin/config/integrations', label: '外部系统对接' }, { path: '/admin/config/integrations', label: '外部系统对接', key: 'admin-config-integrations' },
{ path: '/admin/config/routing', label: '路径规划' }, { path: '/admin/config/routing', label: '路径规划', key: 'admin-config-routing' },
{ path: '/admin/config/vehicle', label: '车辆维护' }, { path: '/admin/config/vehicle', label: '车辆维护', key: 'admin-config-vehicle' },
{ path: '/admin/config/charge', label: '充电策略' }, { path: '/admin/config/charge', label: '充电策略', key: 'admin-config-charge' },
{ path: '/admin/config/task', label: '任务分配' }, { path: '/admin/config/task', label: '任务分配', key: 'admin-config-task' },
{ path: '/admin/config/traffic', label: '交通管制' }, { path: '/admin/config/traffic', label: '交通管制', key: 'admin-config-traffic' },
{ path: '/admin/config/auth', label: '权限与角色' }, { path: '/admin/config/auth', label: '权限与角色', key: 'admin-config-auth' },
{ path: '/admin/config/device', label: '设备接入' }, { path: '/admin/config/device', label: '设备接入', key: 'admin-config-device' },
{ path: '/admin/config/fleet', label: '车队生命周期' }, { path: '/admin/config/fleet', label: '车队生命周期', key: 'admin-config-fleet' },
{ path: '/admin/config/scenario', label: '场景模板' }, { path: '/admin/config/scenario', label: '场景模板', key: 'admin-config-scenario' },
{ path: '/admin/config/location', label: '库位管理' }, { path: '/admin/config/location', label: '库位管理', key: 'admin-config-location' },
{ path: '/admin/config/ops', label: '运营维护' }, { path: '/admin/config/ops', label: '运营维护', key: 'admin-config-ops' },
{ path: '/admin/config/widget', label: '自定义控件' } { path: '/admin/config/widget', label: '自定义控件', key: 'admin-config-widget' }
] ]
} }
] ]
const MONITOR_MENU: MenuItem[] = [ const MONITOR_MENU: MenuItem[] = [
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor }, { path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard' },
{ path: '/monitor/map', label: '地图监控', icon: MapLocation }, { path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map' },
{ path: '/monitor/ops', label: '运维操作', icon: Promotion }, { path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops' },
{ path: '/monitor/notes', label: '运营备注', icon: Notebook } { path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes' }
] ]
const currentMenu = computed<MenuItem[]>(() => (auth.scope === 'Platform' ? ADMIN_MENU : MONITOR_MENU)) function filterMenu(items: MenuItem[]): MenuItem[] {
const out: MenuItem[] = []
for (const m of items) {
if (m.children) {
const kids = m.children.filter((c) => !c.key || auth.hasPage(c.key))
if (kids.length) out.push({ ...m, children: kids })
} else if (!m.key || auth.hasPage(m.key)) {
out.push(m)
}
}
return out
}
const currentMenu = computed<MenuItem[]>(() => filterMenu(auth.scope === 'Platform' ? ADMIN_MENU : MONITOR_MENU))
const activePath = computed(() => { const activePath = computed(() => {
const path = route.path const path = route.path
@@ -447,7 +461,13 @@ function onUserCommand(cmd: string) {
} }
/* ─────────────── Header ─────────────── */ /* ─────────────── Header ─────────────── */
.container-right { background: transparent; } .container-right {
background: transparent;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.header { .header {
position: relative; position: relative;
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
@@ -544,6 +564,11 @@ function onUserCommand(cmd: string) {
/* ─────────────── Main / Footer ─────────────── */ /* ─────────────── Main / Footer ─────────────── */
.main { .main {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
padding: 24px 24px; padding: 24px 24px;
background: transparent; background: transparent;
color: #fff; color: #fff;
@@ -562,11 +587,10 @@ function onUserCommand(cmd: string) {
height: 32px !important; line-height: 32px; height: 32px !important; line-height: 32px;
} }
/* 路由过渡动画 */ .route-page {
.fade-up-enter-active, min-height: 0;
.fade-up-leave-active { flex: 1;
transition: opacity .25s cubic-bezier(.25, .8, .25, 1), transform .25s cubic-bezier(.25, .8, .25, 1); display: flex;
flex-direction: column;
} }
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
.fade-up-leave-to { opacity: 0; transform: translateY(-8px); }
</style> </style>
@@ -0,0 +1,264 @@
import type {
RbacRole, RbacUserView, RbacCatalog, PageDef, OpDef, WidgetDef,
SaveRolePayload, CreateUserPayload, UpdateUserPayload
} from '@/types/rbac'
/**
* 前端 mock 模式(VITE_USE_MOCK=true)下的 RBAC 数据与 CRUD,内存 + localStorage 持久化。
* 与后端 MiGu.Server/Auth/PageCatalog.cs、RbacController KnownOps/Widgets 保持对齐,
* 让无后端时也能完整调试「权限与角色」管理页与按页面权限的菜单/路由控制。
*/
const PAGES: PageDef[] = [
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
{ key: 'admin-playback', label: '调度回放', group: '概览', scope: 'Platform' },
{ key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-tracks', label: '场景管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-cars', label: '车辆管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-missions', label: '任务编排', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-config-system', label: '系统级配置', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-integrations', label: '外部系统对接', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-routing', label: '路径规划', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-vehicle', label: '车辆维护', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-charge', label: '充电策略', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-task', label: '任务分配', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-traffic', label: '交通管制', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-auth', label: '权限与角色', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-device', label: '设备接入', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-fleet', label: '车队生命周期', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-scenario', label: '场景模板', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-location', label: '库位管理', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-ops', label: '运营维护', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-widget', label: '自定义控件', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-map-monitor', label: '地图监控配置', group: '平台配置中心', scope: 'Platform' },
{ key: 'monitor-dashboard', label: '运营总览', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'monitor-map', label: '地图监控', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'monitor-ops', label: '运维操作', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'monitor-notes', label: '运营备注', group: '运营监控', scope: 'RCSMonitor' }
]
const OPS: OpDef[] = [
{ code: '*', label: '全部操作(通配)' },
{ code: 'ops.car.pause', label: '车辆 · 暂停' },
{ code: 'ops.car.resume', label: '车辆 · 恢复' },
{ code: 'ops.car.gohome', label: '车辆 · 回库' },
{ code: 'ops.car.resetSession', label: '车辆 · 重置会话' },
{ code: 'ops.car.manualCharge', label: '车辆 · 手动充电' },
{ code: 'ops.task.pause', label: '任务 · 暂停' },
{ code: 'ops.task.cancel', label: '任务 · 取消' },
{ code: 'ops.task.reassign', label: '任务 · 改派' },
{ code: 'ops.task.boostPriority', label: '任务 · 提升优先级' },
{ code: 'monitor.note.write', label: '监控 · 写运营备注' },
{ code: 'auth.manage', label: '系统 · 权限与角色管理' }
]
const WIDGETS: WidgetDef[] = [
{ id: 'MapEditor', label: '地图编辑器' },
{ id: 'CadToolbar', label: 'CAD 工具栏' },
{ id: 'CarPanel', label: '车辆面板' },
{ id: 'MissionEditor', label: '任务编辑器' },
{ id: 'OpsActionPanel', label: '运维操作面板' },
{ id: 'ConfigCenter', label: '配置中心' }
]
interface RbacState {
roles: RbacRole[]
users: Array<RbacUserView & { password?: string }>
}
const STORAGE_KEY = 'simple-platform-mock-rbac'
function seed(): RbacState {
return {
roles: [
{
id: 'role-admin', name: '超级管理员', description: '拥有全部页面与操作权限的内置角色',
scope: '*', pages: ['*'], ops: ['*'], widgetGrants: [], system: true
},
{
id: 'role-ops', name: '运营人员', description: '运营监控端默认角色:可执行运维操作、查看监控',
scope: 'RCSMonitor',
pages: ['monitor-dashboard', 'monitor-map', 'monitor-ops', 'monitor-notes'],
ops: [
'ops.car.pause', 'ops.car.resume', 'ops.car.gohome', 'ops.car.resetSession',
'ops.car.manualCharge', 'ops.task.pause', 'ops.task.cancel', 'ops.task.reassign',
'ops.task.boostPriority', 'monitor.note.write'
],
widgetGrants: [
{ widgetId: 'MapEditor', visibility: 'readonly' },
{ widgetId: 'CadToolbar', visibility: 'hidden' },
{ widgetId: 'CarPanel', visibility: 'readonly' },
{ widgetId: 'MissionEditor', visibility: 'readonly' },
{ widgetId: 'OpsActionPanel', visibility: 'interactive' },
{ widgetId: 'ConfigCenter', visibility: 'hidden' }
],
system: true
}
],
users: [
{ id: 'u-admin', username: 'admin', displayName: '系统管理员', enabled: true, roleIds: ['role-admin'], scopes: [] },
{ id: 'u-ops', username: 'ops', displayName: '运营人员', enabled: true, roleIds: ['role-ops'], scopes: [] }
]
}
}
function load(): RbacState {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as RbacState
} catch { /* ignore */ }
const s = seed()
persist(s)
return s
}
let state: RbacState = load()
function persist(s: RbacState = state) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)) } catch { /* ignore */ }
}
function scopesOf(roleIds: string[]): string[] {
const set = new Set<string>()
for (const id of roleIds) {
const r = state.roles.find((x) => x.id === id)
if (!r) continue
if (r.scope === '*') { set.add('Platform'); set.add('RCSMonitor') }
else set.add(r.scope)
}
return [...set]
}
function toView(u: RbacUserView & { password?: string }): RbacUserView {
return { id: u.id, username: u.username, displayName: u.displayName, enabled: u.enabled, roleIds: [...u.roleIds], scopes: scopesOf(u.roleIds) }
}
function newId() { return Math.random().toString(36).slice(2, 10) }
function delay(ms: number) { return new Promise((r) => setTimeout(r, ms)) }
function sanitizePages(pages: string[]): string[] {
if (pages.includes('*')) return ['*']
const valid = new Set(PAGES.map((p) => p.key))
return [...new Set(pages.filter((p) => valid.has(p)))]
}
export function mockRbacCatalog(): RbacCatalog {
return {
pages: PAGES,
ops: OPS,
widgets: WIDGETS,
scopes: [
{ value: 'Platform', label: '管理端 (Platform)' },
{ value: 'RCSMonitor', label: '运营端 (RCSMonitor)' },
{ value: '*', label: '通用 (全部域)' }
]
}
}
export async function mockRbacRoles(): Promise<RbacRole[]> {
await delay(60)
return state.roles.map((r) => ({ ...r, pages: [...r.pages], ops: [...r.ops], widgetGrants: [...r.widgetGrants] }))
}
export async function mockRbacSaveRole(payload: SaveRolePayload): Promise<RbacRole> {
await delay(80)
if (!payload.name?.trim()) throw new Error('角色名称不能为空')
const role: RbacRole = {
id: payload.id ?? `role-${newId()}`,
name: payload.name.trim(),
description: payload.description?.trim() ?? '',
scope: payload.scope,
pages: sanitizePages(payload.pages ?? []),
ops: [...new Set(payload.ops ?? [])],
widgetGrants: payload.widgetGrants ?? [],
system: false
}
const idx = payload.id ? state.roles.findIndex((r) => r.id === payload.id) : -1
if (idx >= 0) { role.system = state.roles[idx].system; state.roles[idx] = role } else state.roles.push(role)
persist()
return role
}
export async function mockRbacDeleteRole(id: string): Promise<void> {
await delay(60)
const role = state.roles.find((r) => r.id === id)
if (!role) throw new Error('角色不存在')
if (role.system) throw new Error('内置系统角色不可删除')
const inUse = state.users.filter((u) => u.roleIds.includes(id)).map((u) => u.username)
if (inUse.length) throw new Error(`角色仍被 ${inUse.length} 个用户使用(${inUse.slice(0, 5).join(', ')}),请先解除关联`)
state.roles = state.roles.filter((r) => r.id !== id)
persist()
}
export async function mockRbacUsers(): Promise<RbacUserView[]> {
await delay(60)
return state.users.map(toView)
}
export async function mockRbacCreateUser(payload: CreateUserPayload): Promise<RbacUserView> {
await delay(80)
if (!payload.username?.trim()) throw new Error('用户名不能为空')
if (!payload.password) throw new Error('初始密码不能为空')
if (state.users.some((u) => u.username.toLowerCase() === payload.username.trim().toLowerCase()))
throw new Error(`用户名 ${payload.username} 已存在`)
const valid = new Set(state.roles.map((r) => r.id))
const u = {
id: `u-${newId()}`,
username: payload.username.trim(),
displayName: payload.displayName?.trim() || payload.username.trim(),
enabled: payload.enabled,
roleIds: [...new Set((payload.roleIds ?? []).filter((r) => valid.has(r)))],
scopes: [] as string[],
password: payload.password
}
state.users.push(u)
persist()
return toView(u)
}
export async function mockRbacUpdateUser(id: string, payload: UpdateUserPayload): Promise<RbacUserView> {
await delay(80)
const u = state.users.find((x) => x.id === id)
if (!u) throw new Error('用户不存在')
if (payload.displayName !== undefined) u.displayName = payload.displayName.trim()
if (payload.roleIds !== undefined) {
const valid = new Set(state.roles.map((r) => r.id))
u.roleIds = [...new Set(payload.roleIds.filter((r) => valid.has(r)))]
}
if (payload.enabled !== undefined) u.enabled = payload.enabled
persist()
return toView(u)
}
export async function mockRbacSetPassword(id: string, password: string): Promise<void> {
await delay(60)
const u = state.users.find((x) => x.id === id)
if (!u) throw new Error('用户不存在')
if (!password) throw new Error('密码不能为空')
u.password = password
persist()
}
export async function mockRbacDeleteUser(id: string): Promise<void> {
await delay(60)
if (!state.users.some((u) => u.id === id)) throw new Error('用户不存在')
state.users = state.users.filter((u) => u.id !== id)
persist()
}
/** 供 mockLogin 计算 allowedPages:按 username + scope 取角色 pages 并集;未知用户给该 scope 全部页面。 */
export function mockComputeAllowedPages(username: string, scope: 'Platform' | 'RCSMonitor'): string[] {
const scopeKeys = PAGES.filter((p) => p.scope === scope).map((p) => p.key)
const u = state.users.find((x) => x.username.toLowerCase() === username.toLowerCase())
if (!u) return scopeKeys
const roles = state.roles.filter((r) => u.roleIds.includes(r.id) && (r.scope === '*' || r.scope === scope))
const set = new Set<string>()
for (const r of roles) {
if (r.pages.includes('*')) scopeKeys.forEach((k) => set.add(k))
else r.pages.filter((p) => scopeKeys.includes(p)).forEach((k) => set.add(k))
}
return [...set]
}
@@ -9,6 +9,7 @@ import { TRACKS } from './data/tracks'
import { CARS } from './data/cars' import { CARS } from './data/cars'
import { MISSIONS } from './data/missions' import { MISSIONS } from './data/missions'
import { CONFIG_DEFAULTS } from './data/configs' import { CONFIG_DEFAULTS } from './data/configs'
import { mockComputeAllowedPages } from './rbac'
const PLATFORM_OPS = ['*'] const PLATFORM_OPS = ['*']
@@ -26,6 +27,7 @@ function buildPerm(scope: 'Platform' | 'RCSMonitor', username: string): Effectiv
userId: `u-${username}`, userId: `u-${username}`,
version: 1, version: 1,
allowedOps: PLATFORM_OPS, allowedOps: PLATFORM_OPS,
allowedPages: mockComputeAllowedPages(username, 'Platform'),
visibleWidgets: [ visibleWidgets: [
{ widgetId: 'MapEditor', visibility: 'interactive' }, { widgetId: 'MapEditor', visibility: 'interactive' },
{ widgetId: 'CadToolbar', visibility: 'interactive' }, { widgetId: 'CadToolbar', visibility: 'interactive' },
@@ -40,6 +42,7 @@ function buildPerm(scope: 'Platform' | 'RCSMonitor', username: string): Effectiv
userId: `u-${username}`, userId: `u-${username}`,
version: 1, version: 1,
allowedOps: RCS_OPS, allowedOps: RCS_OPS,
allowedPages: mockComputeAllowedPages(username, 'RCSMonitor'),
visibleWidgets: [ visibleWidgets: [
{ widgetId: 'MapEditor', visibility: 'readonly' }, { widgetId: 'MapEditor', visibility: 'readonly' },
{ widgetId: 'CadToolbar', visibility: 'hidden' }, { widgetId: 'CadToolbar', visibility: 'hidden' },
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
@@ -63,6 +64,20 @@ const routes: RouteRecordRaw[] = [
{ path: '/:pathMatch(.*)*', redirect: '/login' } { path: '/:pathMatch(.*)*', redirect: '/login' }
] ]
/**
* RBAC 页面权限:route.name 即「权限页面 Key」(与后端 PageCatalog / EffectivePermissions.allowedPages 对齐)。
* 这里从路由表派生出各 scope 的有序页面列表,供守卫做权限校验与「跳到首个可访问页面」的回退。
*/
function childrenPages(parentPath: string): Array<{ name: string; path: string }> {
const parent = routes.find((r) => r.path === parentPath)
return ((parent?.children ?? []) as RouteRecordRaw[])
.filter((c) => typeof c.name === 'string')
.map((c) => ({ name: String(c.name), path: `${parentPath}/${(c as { path: string }).path}` }))
}
const ADMIN_PAGES = childrenPages('/admin')
const MONITOR_PAGES = childrenPages('/monitor')
const MANAGED_PAGE_NAMES = new Set([...ADMIN_PAGES, ...MONITOR_PAGES].map((p) => p.name))
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes
@@ -93,13 +108,35 @@ router.beforeEach(async (to) => {
// 会话 45 AR-6switchScope 改为后端发起 —— // 会话 45 AR-6switchScope 改为后端发起 ——
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。 // 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。 // 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
try { const needScope: 'Platform' | 'RCSMonitor' | null =
if (to.path.startsWith('/admin') && auth.scope !== 'Platform') { to.path.startsWith('/admin') ? 'Platform'
await auth.switchScope('Platform') : to.path.startsWith('/monitor') ? 'RCSMonitor'
} else if (to.path.startsWith('/monitor') && auth.scope !== 'RCSMonitor') { : null
await auth.switchScope('RCSMonitor') if (needScope && auth.scope !== needScope) {
try {
await auth.switchScope(needScope)
} catch (_) {
// 偶发失败(网络/超时)重试一次:避免带着「另一个域」的 effectivePermissions 渲染当前页,
// 否则配置页会被 PermissionGuard 误判为隐藏 → 整页空白(刷新后重新 switch 才恢复)。
try { await auth.switchScope(needScope) } catch (_2) { /* 仍失败:维持原 scopeUI 多为 readonly */ }
} }
} catch (_) { /* 服务端拒绝切换:维持原 scope,UI 会按现有 perms 渲染(多为 readonly */ } }
// RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。
// 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。
const name = typeof to.name === 'string' ? to.name : ''
if (name && MANAGED_PAGE_NAMES.has(name) && !auth.hasPage(name)) {
const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES
const fallback = list.find((p) => auth.hasPage(p.name))?.path
if (fallback && fallback !== to.path) {
ElMessage.warning('无权访问该页面,已跳转到可访问页面')
return fallback
}
if (!fallback) {
ElMessage.error('当前账号在该区域没有任何可访问页面,请联系管理员分配权限')
return { path: '/status' }
}
}
return true return true
}) })
@@ -108,4 +145,15 @@ router.afterEach((to) => {
document.title = to.meta.title ? `${to.meta.title} · ${base}` : base document.title = to.meta.title ? `${to.meta.title} · ${base}` : base
}) })
// 懒加载 chunk 失败自愈:重新部署后产物 hash 变化、或网络抖动会让动态 import() 失败,
// 表现为点击导航后白屏、刷新才好。这里捕获该类错误并整页重载到目标路由,
// 让浏览器重新拉取最新的 index.html 与对应 chunk 引用。
router.onError((error, to) => {
const m = (error as Error)?.message ?? ''
if (/dynamically imported module|Loading (chunk|CSS chunk)|module script failed|Failed to fetch/i.test(m)) {
if (to?.fullPath) window.location.assign(to.fullPath)
else window.location.reload()
}
})
export default router export default router
@@ -85,6 +85,16 @@ export const useAuthStore = defineStore('auth', {
(widgetId: string): 'hidden' | 'readonly' | 'interactive' => { (widgetId: string): 'hidden' | 'readonly' | 'interactive' => {
const grant = s.effectivePermissions?.visibleWidgets.find((w) => w.widgetId === widgetId) const grant = s.effectivePermissions?.visibleWidgets.find((w) => w.widgetId === widgetId)
return grant?.visibility ?? 'interactive' return grant?.visibility ?? 'interactive'
},
/**
* 当前用户在当前 scope 下是否可访问指定页面(route.name)。
* '*' 视为全部页面(兜底兼容)。菜单过滤与路由守卫共用此判断。
*/
hasPage:
(s) =>
(key: string): boolean => {
const pages = s.effectivePermissions?.allowedPages ?? []
return pages.includes('*') || pages.includes(key)
} }
}, },
actions: { actions: {
@@ -31,6 +31,12 @@ export interface EffectivePermissions {
version: number version: number
allowedOps: string[] allowedOps: string[]
visibleWidgets: WidgetGrant[] visibleWidgets: WidgetGrant[]
/**
* 当前 scope 下可访问的「权限页面」Key 集合(与 vue-router route.name 对齐)。
* 由后端按用户角色的并集计算并下发;菜单渲染与路由守卫据此放行。
* 后端通常已把通配展开为具体 Key;前端仍兼容 '*' 通配(mock / 历史数据)。
*/
allowedPages: string[]
} }
export interface AuthUser { export interface AuthUser {
@@ -0,0 +1,84 @@
import type { WidgetGrant } from './auth'
/** 角色归属域:Platform(管理端)/ RCSMonitor(运营端)/ *(通用,对两域生效)。 */
export type RbacScope = 'Platform' | 'RCSMonitor' | '*'
/** 角色:一组「页面 + 操作码 + 控件可见性」授权。 */
export interface RbacRole {
id: string
name: string
description: string
scope: RbacScope
/** 可访问页面 Key;含 '*' 表示该 scope 全部页面。 */
pages: string[]
/** 操作码;含 '*' 表示全部操作。 */
ops: string[]
widgetGrants: WidgetGrant[]
/** 内置系统角色,前端禁止删除。 */
system: boolean
}
/** 用户视图(不含密码)。scopes 为该用户可登录的 scope 集合。 */
export interface RbacUserView {
id: string
username: string
displayName: string
enabled: boolean
roleIds: string[]
scopes: string[]
}
/** 权限页面定义(catalog 项)。 */
export interface PageDef {
key: string
label: string
group: string
scope: string
}
export interface OpDef {
code: string
label: string
}
export interface WidgetDef {
id: string
label: string
}
export interface ScopeOption {
value: RbacScope
label: string
}
/** 权限「字典」:页面 + 操作 + 控件 + scope 选项,供角色编辑器渲染勾选项。 */
export interface RbacCatalog {
pages: PageDef[]
ops: OpDef[]
widgets: WidgetDef[]
scopes: ScopeOption[]
}
export interface SaveRolePayload {
id?: string
name: string
description?: string
scope: RbacScope
pages: string[]
ops: string[]
widgetGrants: WidgetGrant[]
}
export interface CreateUserPayload {
username: string
displayName?: string
password: string
roleIds: string[]
enabled: boolean
}
export interface UpdateUserPayload {
displayName?: string
roleIds?: string[]
enabled?: boolean
}
@@ -1,57 +1,410 @@
<template> <template>
<ConfigPageBase <el-card shadow="never" class="rbac-card">
section="auth" <template #header>
title="权限与角色管理" <div class="rbac-header">
description="角色、权限码、控件级 WidgetGrantauth.user.write / auth.role.write" <div class="rbac-title-wrap">
:defaults="DEFAULT_AUTH"> <span class="rbac-title">权限与角色管理</span>
<template #default="{ payload }"> <span class="rbac-desc">为角色分配可访问页面操作与控件并为用户指派角色标准 RBAC</span>
<el-tabs model-value="roles"> </div>
<el-tab-pane name="roles" label="角色"> <el-button :icon="Refresh" :loading="loading" @click="reloadAll">刷新</el-button>
<el-table :data="payload.roles" size="small" border> </div>
<el-table-column label="ID" prop="id" width="120" />
<el-table-column label="名称" prop="name" width="120" />
<el-table-column label="Scope" prop="scope" width="120" />
<el-table-column label="权限码">
<template #default="s">
<el-tag v-for="p in s.row.permissions" :key="p" size="small" effect="plain" style="margin: 2px">{{ p }}</el-tag>
</template>
</el-table-column>
<el-table-column label="WidgetGrant" width="280">
<template #default="s">
<el-tag v-for="g in s.row.widgetGrants" :key="g.widgetId" size="small" :type="grantTag(g.visibility)" effect="plain" style="margin: 2px">{{ g.widgetId }}={{ g.visibility }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="users" label="用户">
<el-table :data="payload.users" size="small" border>
<el-table-column label="ID" prop="id" width="120" />
<el-table-column label="用户名" prop="username" width="140" />
<el-table-column label="角色">
<template #default="s">
<el-tag v-for="r in s.row.roles" :key="r" size="small">{{ r }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="s"><el-switch v-model="s.row.enabled" /></template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template> </template>
</ConfigPageBase>
<el-tabs v-model="activeTab" class="rbac-tabs">
<!-- 用户 -->
<el-tab-pane label="用户管理" name="users">
<div class="tab-toolbar">
<span class="toolbar-hint"> {{ users.length }} 个用户</span>
<el-button type="primary" :icon="Plus" @click="openCreateUser">新增用户</el-button>
</div>
<el-table :data="users" v-loading="loading" size="default" border stripe>
<el-table-column label="用户名" prop="username" width="140" />
<el-table-column label="显示名" prop="displayName" width="150" />
<el-table-column label="角色" min-width="200">
<template #default="{ row }">
<el-tag v-for="rid in row.roleIds" :key="rid" size="small" effect="plain" class="mg-tag">{{ roleName(rid) }}</el-tag>
<span v-if="!row.roleIds.length" class="muted"></span>
</template>
</el-table-column>
<el-table-column label="可用域" width="180">
<template #default="{ row }">
<el-tag v-for="s in row.scopes" :key="s" size="small" type="info" effect="plain" class="mg-tag">{{ scopeLabel(s) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="row.enabled" :loading="row._busy" @change="(v: string | number | boolean) => toggleEnabled(row, Boolean(v))" />
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="openEditUser(row)">编辑</el-button>
<el-button link type="warning" :icon="Key" @click="openResetPassword(row)">改密</el-button>
<el-button link type="danger" :icon="Delete" :disabled="row.id === selfId" @click="removeUser(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 角色 -->
<el-tab-pane label="角色与页面权限" name="roles">
<div class="tab-toolbar">
<span class="toolbar-hint"> {{ roles.length }} 个角色</span>
<el-button type="primary" :icon="Plus" @click="openCreateRole">新增角色</el-button>
</div>
<el-table :data="roles" v-loading="loading" size="default" border stripe>
<el-table-column label="角色名称" width="160">
<template #default="{ row }">
<span>{{ row.name }}</span>
<el-tag v-if="row.system" size="small" type="success" effect="plain" class="mg-tag">内置</el-tag>
</template>
</el-table-column>
<el-table-column label="描述" prop="description" min-width="180" show-overflow-tooltip />
<el-table-column label="归属域" width="120">
<template #default="{ row }"><el-tag size="small" :type="scopeTag(row.scope)" effect="plain">{{ scopeLabel(row.scope) }}</el-tag></template>
</el-table-column>
<el-table-column label="可访问页面" width="130" align="center">
<template #default="{ row }">
<el-tag v-if="row.pages.includes('*')" size="small" type="danger" effect="plain">全部页面</el-tag>
<span v-else>{{ row.pages.length }} </span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="openEditRole(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" :disabled="row.system" @click="removeRole(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 用户 Dialog -->
<el-dialog v-model="userDialog" :title="userForm.id ? '编辑用户' : '新增用户'" width="480px" destroy-on-close>
<el-form label-width="84px" label-position="right">
<el-form-item label="用户名" required>
<el-input v-model="userForm.username" :disabled="!!userForm.id" placeholder="登录用户名" />
</el-form-item>
<el-form-item label="显示名">
<el-input v-model="userForm.displayName" placeholder="界面展示名称(留空则同用户名)" />
</el-form-item>
<el-form-item v-if="!userForm.id" label="初始密码" required>
<el-input v-model="userForm.password" type="password" show-password placeholder="设置初始登录密码" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="userForm.roleIds" multiple filterable placeholder="选择一个或多个角色" style="width: 100%">
<el-option v-for="r in roles" :key="r.id" :label="`${r.name}${scopeLabel(r.scope)}`" :value="r.id" />
</el-select>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="userForm.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitUser">保存</el-button>
</template>
</el-dialog>
<!-- 改密 Dialog -->
<el-dialog v-model="pwdDialog" title="重置密码" width="420px" destroy-on-close>
<el-form label-width="84px">
<el-form-item label="用户">
<span>{{ pwdTarget?.displayName }}{{ pwdTarget?.username }}</span>
</el-form-item>
<el-form-item label="新密码" required>
<el-input v-model="newPassword" type="password" show-password placeholder="输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitPassword">确定</el-button>
</template>
</el-dialog>
<!-- 角色 Dialog -->
<el-dialog v-model="roleDialog" :title="roleForm.id ? '编辑角色' : '新增角色'" width="760px" top="6vh" destroy-on-close>
<el-form label-width="92px" label-position="right">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="角色名称" required>
<el-input v-model="roleForm.name" placeholder="如:场景设计员" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="归属域" required>
<el-select v-model="roleForm.scope" style="width: 100%" @change="onScopeChange">
<el-option v-for="s in catalog?.scopes ?? []" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述">
<el-input v-model="roleForm.description" placeholder="角色用途说明" />
</el-form-item>
<el-divider content-position="left">可访问页面</el-divider>
<el-form-item label="授权方式">
<el-switch v-model="wildcardPages" active-text="该域全部页面(含未来新增)" inactive-text="按页面精确勾选" inline-prompt style="--el-switch-on-color: var(--mg-primary, #6366f1)" />
</el-form-item>
<div v-if="!wildcardPages" class="page-picker">
<div v-for="g in groupedPages" :key="g.group" class="page-group">
<div class="page-group-head">
<span class="page-group-title">{{ g.group }}</span>
<span class="page-group-actions">
<el-button link type="primary" size="small" @click="selectGroup(g.pages, true)">全选</el-button>
<el-button link type="info" size="small" @click="selectGroup(g.pages, false)">清空</el-button>
</span>
</div>
<el-checkbox-group v-model="selectedPages" class="page-checks">
<el-checkbox v-for="p in g.pages" :key="p.key" :value="p.key" :label="p.key">{{ p.label }}</el-checkbox>
</el-checkbox-group>
</div>
<el-empty v-if="!groupedPages.length" description="该域暂无可分配页面" :image-size="60" />
</div>
<div v-else class="wildcard-hint">已授予{{ scopeLabel(roleForm.scope) }}下的全部页面包括未来新增的页面</div>
</el-form>
<template #footer>
<el-button @click="roleDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitRole">保存</el-button>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue' import { computed, onMounted, reactive, ref } from 'vue'
import { DEFAULT_AUTH } from '@/mock/data/configs' import { Plus, Edit, Delete, Key, Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import {
fetchCatalog, fetchRoles, fetchUsers,
saveRole, deleteRole, createUser, updateUser, setUserPassword, deleteUser
} from '@/api/rbac'
import type { RbacRole, RbacUserView, RbacCatalog, PageDef, RbacScope } from '@/types/rbac'
import type { WidgetGrant } from '@/types/auth'
function grantTag(v: string) { const auth = useAuthStore()
switch (v) { const selfId = computed(() => auth.user?.id ?? '')
case 'hidden': return 'danger'
case 'readonly': return 'warning' const activeTab = ref<'users' | 'roles'>('users')
case 'interactive': return 'success' const loading = ref(false)
default: return 'info' const saving = ref(false)
const catalog = ref<RbacCatalog | null>(null)
const roles = ref<RbacRole[]>([])
const users = ref<Array<RbacUserView & { _busy?: boolean }>>([])
function roleName(id: string): string {
return roles.value.find((r) => r.id === id)?.name ?? id
}
function scopeLabel(scope: string): string {
return scope === '*' ? '通用' : scope === 'Platform' ? '管理端' : scope === 'RCSMonitor' ? '运营端' : scope
}
function scopeTag(scope: string): 'success' | 'warning' | 'danger' | 'info' {
return scope === '*' ? 'danger' : scope === 'Platform' ? 'warning' : 'success'
}
async function reloadAll() {
loading.value = true
try {
const [cat, rs, us] = await Promise.all([fetchCatalog(), fetchRoles(), fetchUsers()])
catalog.value = cat
roles.value = rs
users.value = us
} catch (e) {
ElMessage.error(`加载失败:${msg(e)}`)
} finally {
loading.value = false
} }
} }
// ───────────────────────── 用户 ─────────────────────────
const userDialog = ref(false)
const userForm = reactive<{ id: string | null; username: string; displayName: string; password: string; roleIds: string[]; enabled: boolean }>({
id: null, username: '', displayName: '', password: '', roleIds: [], enabled: true
})
function openCreateUser() {
Object.assign(userForm, { id: null, username: '', displayName: '', password: '', roleIds: [], enabled: true })
userDialog.value = true
}
function openEditUser(row: RbacUserView) {
Object.assign(userForm, { id: row.id, username: row.username, displayName: row.displayName, password: '', roleIds: [...row.roleIds], enabled: row.enabled })
userDialog.value = true
}
async function submitUser() {
saving.value = true
try {
if (userForm.id) {
await updateUser(userForm.id, { displayName: userForm.displayName, roleIds: userForm.roleIds, enabled: userForm.enabled })
ElMessage.success('用户已更新')
} else {
await createUser({ username: userForm.username, displayName: userForm.displayName, password: userForm.password, roleIds: userForm.roleIds, enabled: userForm.enabled })
ElMessage.success('用户已创建')
}
userDialog.value = false
await reloadAll()
} catch (e) {
ElMessage.error(msg(e))
} finally {
saving.value = false
}
}
async function toggleEnabled(row: RbacUserView & { _busy?: boolean }, val: boolean) {
row._busy = true
try {
await updateUser(row.id, { enabled: val })
row.enabled = val
ElMessage.success(val ? '已启用' : '已停用')
} catch (e) {
ElMessage.error(msg(e))
} finally {
row._busy = false
}
}
async function removeUser(row: RbacUserView) {
try {
await ElMessageBox.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`, '删除确认', { type: 'warning' })
} catch { return }
try {
await deleteUser(row.id)
ElMessage.success('用户已删除')
await reloadAll()
} catch (e) {
ElMessage.error(msg(e))
}
}
// 改密
const pwdDialog = ref(false)
const pwdTarget = ref<RbacUserView | null>(null)
const newPassword = ref('')
function openResetPassword(row: RbacUserView) {
pwdTarget.value = row
newPassword.value = ''
pwdDialog.value = true
}
async function submitPassword() {
if (!pwdTarget.value) return
if (!newPassword.value) { ElMessage.warning('请输入新密码'); return }
saving.value = true
try {
await setUserPassword(pwdTarget.value.id, newPassword.value)
ElMessage.success('密码已重置')
pwdDialog.value = false
} catch (e) {
ElMessage.error(msg(e))
} finally {
saving.value = false
}
}
// ───────────────────────── 角色 ─────────────────────────
const roleDialog = ref(false)
const roleForm = reactive<{ id: string | null; name: string; description: string; scope: RbacScope }>({
id: null, name: '', description: '', scope: 'Platform'
})
const selectedPages = ref<string[]>([])
const wildcardPages = ref(false)
// 操作权限 / 控件可见性不在本界面暴露编辑:编辑时记录原值、保存时原样回传,
// 避免把内置角色(如运营)已有的运维操作码 / 控件可见性配置清空。
const preservedOps = ref<string[]>([])
const preservedWidgets = ref<WidgetGrant[]>([])
const availablePages = computed<PageDef[]>(() =>
(catalog.value?.pages ?? []).filter((p) => roleForm.scope === '*' || p.scope === roleForm.scope))
const groupedPages = computed(() => {
const map = new Map<string, PageDef[]>()
for (const p of availablePages.value) {
if (!map.has(p.group)) map.set(p.group, [])
map.get(p.group)!.push(p)
}
return [...map.entries()].map(([group, pages]) => ({ group, pages }))
})
function openCreateRole() {
Object.assign(roleForm, { id: null, name: '', description: '', scope: 'Platform' })
selectedPages.value = []
wildcardPages.value = false
preservedOps.value = []
preservedWidgets.value = []
roleDialog.value = true
}
function openEditRole(row: RbacRole) {
Object.assign(roleForm, { id: row.id, name: row.name, description: row.description, scope: row.scope })
wildcardPages.value = row.pages.includes('*')
selectedPages.value = wildcardPages.value ? [] : [...row.pages]
preservedOps.value = [...row.ops]
preservedWidgets.value = row.widgetGrants.map((g) => ({ ...g }))
roleDialog.value = true
}
function onScopeChange() {
// 切换归属域后,剔除不属于新域的已选页面
const valid = new Set(availablePages.value.map((p) => p.key))
selectedPages.value = selectedPages.value.filter((k) => valid.has(k))
}
function selectGroup(pages: PageDef[], on: boolean) {
const keys = pages.map((p) => p.key)
if (on) selectedPages.value = [...new Set([...selectedPages.value, ...keys])]
else selectedPages.value = selectedPages.value.filter((k) => !keys.includes(k))
}
async function submitRole() {
if (!roleForm.name.trim()) { ElMessage.warning('请填写角色名称'); return }
saving.value = true
try {
await saveRole({
id: roleForm.id ?? undefined,
name: roleForm.name,
description: roleForm.description,
scope: roleForm.scope,
pages: wildcardPages.value ? ['*'] : selectedPages.value,
ops: preservedOps.value,
widgetGrants: preservedWidgets.value
})
ElMessage.success(roleForm.id ? '角色已更新' : '角色已创建')
roleDialog.value = false
await reloadAll()
} catch (e) {
ElMessage.error(msg(e))
} finally {
saving.value = false
}
}
async function removeRole(row: RbacRole) {
try {
await ElMessageBox.confirm(`确定删除角色「${row.name}」?`, '删除确认', { type: 'warning' })
} catch { return }
try {
await deleteRole(row.id)
ElMessage.success('角色已删除')
await reloadAll()
} catch (e) {
ElMessage.error(msg(e))
}
}
function msg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
onMounted(reloadAll)
</script> </script>
<style scoped>
.rbac-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.rbac-title-wrap { display: flex; flex-direction: column; gap: 4px; }
.rbac-title { font-weight: 600; font-size: 16px; color: var(--mg-text-light); }
.rbac-desc { font-size: 12.5px; color: var(--mg-text-muted); }
.tab-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.toolbar-hint { font-size: 13px; color: var(--mg-text-muted); }
.mg-tag { margin: 2px 4px 2px 0; }
.muted { color: var(--mg-text-faint); }
.page-picker { display: flex; flex-direction: column; gap: 14px; max-height: 320px; overflow-y: auto; padding: 4px 2px; }
.page-group-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.page-group-title { font-weight: 600; font-size: 13px; color: var(--mg-accent, #c4b5fd); }
.page-checks { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.wildcard-hint { font-size: 13px; color: var(--mg-status-warning, #f59e0b); padding: 4px 0 8px; }
</style>