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:
@@ -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-6:switchScope 已变成异步(调后端 /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) {
|
||||||
|
if (switching) return
|
||||||
|
switching = true
|
||||||
|
try {
|
||||||
await switchAndNavigate(v as Scope)
|
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-6:switchScope 改为后端发起 ——
|
// 会话 45 AR-6:switchScope 改为后端发起 ——
|
||||||
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
|
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
|
||||||
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
|
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
|
||||||
|
const needScope: 'Platform' | 'RCSMonitor' | null =
|
||||||
|
to.path.startsWith('/admin') ? 'Platform'
|
||||||
|
: to.path.startsWith('/monitor') ? 'RCSMonitor'
|
||||||
|
: null
|
||||||
|
if (needScope && auth.scope !== needScope) {
|
||||||
try {
|
try {
|
||||||
if (to.path.startsWith('/admin') && auth.scope !== 'Platform') {
|
await auth.switchScope(needScope)
|
||||||
await auth.switchScope('Platform')
|
} catch (_) {
|
||||||
} else if (to.path.startsWith('/monitor') && auth.scope !== 'RCSMonitor') {
|
// 偶发失败(网络/超时)重试一次:避免带着「另一个域」的 effectivePermissions 渲染当前页,
|
||||||
await auth.switchScope('RCSMonitor')
|
// 否则配置页会被 PermissionGuard 误判为隐藏 → 整页空白(刷新后重新 switch 才恢复)。
|
||||||
|
try { await auth.switchScope(needScope) } catch (_2) { /* 仍失败:维持原 scope,UI 多为 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' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) { /* 服务端拒绝切换:维持原 scope,UI 会按现有 perms 渲染(多为 readonly) */ }
|
|
||||||
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="角色、权限码、控件级 WidgetGrant(auth.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" />
|
</template>
|
||||||
<el-table-column label="名称" prop="name" width="120" />
|
|
||||||
<el-table-column label="Scope" prop="scope" width="120" />
|
<el-tabs v-model="activeTab" class="rbac-tabs">
|
||||||
<el-table-column label="权限码">
|
<!-- ───────────────────────── 用户 ───────────────────────── -->
|
||||||
<template #default="s">
|
<el-tab-pane label="用户管理" name="users">
|
||||||
<el-tag v-for="p in s.row.permissions" :key="p" size="small" effect="plain" style="margin: 2px">{{ p }}</el-tag>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="WidgetGrant" width="280">
|
<el-table-column label="可用域" width="180">
|
||||||
<template #default="s">
|
<template #default="{ row }">
|
||||||
<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>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</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-tab-pane label="角色与页面权限" name="roles">
|
||||||
<el-table-column label="用户名" prop="username" width="140" />
|
<div class="tab-toolbar">
|
||||||
<el-table-column label="角色">
|
<span class="toolbar-hint">共 {{ roles.length }} 个角色</span>
|
||||||
<template #default="s">
|
<el-button type="primary" :icon="Plus" @click="openCreateRole">新增角色</el-button>
|
||||||
<el-tag v-for="r in s.row.roles" :key="r" size="small">{{ r }}</el-tag>
|
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="启用" width="80">
|
<el-table-column label="描述" prop="description" min-width="180" show-overflow-tooltip />
|
||||||
<template #default="s"><el-switch v-model="s.row.enabled" /></template>
|
<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-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</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>
|
</template>
|
||||||
</ConfigPageBase>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user