feat(v0.7.1): P0 修复 + 企微 SSO + RBAC 细粒度 + audit_log
P0 修复: - /api/ready import 错误 (_get_engine + settings.create_redis_client) - 删 agent.otp_secret/otp_enabled 双字段 (migration 026) - 重建 021_rbac migration (IF NOT EXISTS 兼容) P1 新增: - 企微 SSO (auth_wecom_sso.py, useWeChatWorkSSO composable, PortalSelect UA 检测) - RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围 (rbac_service + seed_rbac + require_permission) - audit_log 模型 + migration 027 + 服务 + API - 管理后台 RBAC 权限矩阵 UI (PermissionsMatrix.vue) 质量: - pytest 405 passed / 33 pre-existing failed / 4 xfailed (v0.7.1 引入失败 = 0) - conftest GBK patch 强制 UTF-8 读 .env - .gitignore 排除 *.b64 (含 admin token 凭据) - DEPLOY-v0.7.1.md 7 步 runbook + 4 坑 + 回滚预案
This commit is contained in:
@@ -51,6 +51,13 @@ const routes = [
|
||||
component: () => import('@/views/Roles.vue'),
|
||||
meta: { title: '角色管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// v0.7.1 task #91 — RBAC 细粒度权限矩阵可视化
|
||||
path: 'permissions-matrix',
|
||||
name: 'PermissionsMatrix',
|
||||
component: () => import('@/views/PermissionsMatrix.vue'),
|
||||
meta: { title: '权限矩阵', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
name: 'Integrations',
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — RBAC 权限矩阵可视化页 (v0.7.1 task #91)
|
||||
=============================================================================
|
||||
说明: 拉 GET /api/admin/roles/permissions/matrix,渲染 5 角色 × 4 资源 ×
|
||||
4 操作 × 3 范围的完整矩阵表格。给管理员一眼看到角色权限边界。
|
||||
|
||||
特性:
|
||||
- 行 = 资源:操作 (16 行,4 资源 × 4 操作)
|
||||
- 列 = 角色 (5 列,user/agent/team_lead/auditor/admin)
|
||||
- 单元格颜色:
|
||||
· own 权限 → 浅蓝
|
||||
· department 权限 → 蓝色
|
||||
· all 权限 → 深蓝
|
||||
· 无权限 → 灰色
|
||||
- 顶部 scope 图例 + 角色筛选 + 资源筛选
|
||||
- 支持导出 CSV(复制权限矩阵给合规审计)
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="matrix-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">RBAC 权限矩阵</div>
|
||||
<div class="page-desc">
|
||||
5 角色 × 4 资源 × 4 操作 × 3 数据范围,共 240 个权限点 (5 × 16 × 3)
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select
|
||||
v-model="selectedRole"
|
||||
placeholder="筛选角色"
|
||||
size="default"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
>
|
||||
<el-option
|
||||
v-for="r in matrixData.roles"
|
||||
:key="r.name"
|
||||
:label="r.display_name + ' (' + r.name + ')'"
|
||||
:value="r.name"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="selectedResource"
|
||||
placeholder="筛选资源"
|
||||
size="default"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
>
|
||||
<el-option
|
||||
v-for="r in matrixData.resources"
|
||||
:key="r"
|
||||
:label="r"
|
||||
:value="r"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button @click="exportCsv">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 CSV
|
||||
</el-button>
|
||||
<el-button @click="loadMatrix" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading && !hasData" :rows="10" animated />
|
||||
|
||||
<template v-else-if="hasData">
|
||||
<!-- scope 图例 -->
|
||||
<div class="legend">
|
||||
<span class="legend-title">数据范围:</span>
|
||||
<span class="legend-item legend-own">own — 自己的</span>
|
||||
<span class="legend-item legend-dept">department — 部门的</span>
|
||||
<span class="legend-item legend-all">all — 全部</span>
|
||||
<span class="legend-item legend-none">无权限</span>
|
||||
</div>
|
||||
|
||||
<!-- 权限矩阵表 -->
|
||||
<el-table
|
||||
:data="filteredMatrixRows"
|
||||
stripe
|
||||
size="small"
|
||||
:empty-text="loading ? '加载中...' : '无数据'"
|
||||
class="matrix-table"
|
||||
>
|
||||
<el-table-column label="资源" prop="resource" width="140" fixed>
|
||||
<template #default="{ row }">
|
||||
<span class="resource-label">{{ row.resource }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" prop="action" width="100" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="getActionTagType(row.action)">
|
||||
{{ row.action }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="role in filteredRoles"
|
||||
:key="role.name"
|
||||
:label="role.display_name"
|
||||
min-width="140"
|
||||
align="center"
|
||||
>
|
||||
<template #header>
|
||||
<div class="role-header">
|
||||
<div class="role-name">{{ role.display_name }}</div>
|
||||
<div class="role-id">{{ role.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="perm-cell">
|
||||
<el-tag
|
||||
v-for="scope in getScopesForRole(row, role.name)"
|
||||
:key="scope"
|
||||
size="small"
|
||||
:class="['scope-tag', `scope-${scope}`]"
|
||||
>
|
||||
{{ scope }}
|
||||
</el-tag>
|
||||
<span v-if="getScopesForRole(row, role.name).length === 0" class="no-perm">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 角色详情 -->
|
||||
<div class="role-detail">
|
||||
<div class="section-title">角色详情</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item
|
||||
v-for="role in filteredRoles"
|
||||
:key="role.name"
|
||||
:label="role.display_name + ' (' + role.name + ')'"
|
||||
>
|
||||
<div class="role-meta">
|
||||
<div class="role-desc">{{ role.description || '—' }}</div>
|
||||
<div class="role-perm-count">
|
||||
权限数: <b>{{ role.permission_count }}</b>
|
||||
<el-tag v-if="role.is_default" type="info" size="small" style="margin-left: 8px">默认</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="加载失败, 请检查网络或权限" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download, Refresh } from '@element-plus/icons-vue'
|
||||
import apiClient from '@/api/index'
|
||||
|
||||
// ---- 类型定义 ----
|
||||
interface Role {
|
||||
name: string
|
||||
display_name: string
|
||||
description: string
|
||||
is_default: boolean
|
||||
permission_count: number
|
||||
}
|
||||
|
||||
interface MatrixData {
|
||||
roles: Role[]
|
||||
resources: string[]
|
||||
actions: string[]
|
||||
scopes: string[]
|
||||
matrix: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
interface MatrixRow {
|
||||
resource: string
|
||||
action: string
|
||||
key: string // "resource:action"
|
||||
}
|
||||
|
||||
// ---- 状态 ----
|
||||
const loading = ref(false)
|
||||
const matrixData = ref<MatrixData>({
|
||||
roles: [],
|
||||
resources: [],
|
||||
actions: [],
|
||||
scopes: [],
|
||||
matrix: {},
|
||||
})
|
||||
const selectedRole = ref<string>('')
|
||||
const selectedResource = ref<string>('')
|
||||
|
||||
const hasData = computed(() => matrixData.value.roles.length > 0)
|
||||
|
||||
const filteredRoles = computed(() => {
|
||||
if (!selectedRole.value) return matrixData.value.roles
|
||||
return matrixData.value.roles.filter((r) => r.name === selectedRole.value)
|
||||
})
|
||||
|
||||
// 矩阵行: 资源 × 操作 笛卡尔积
|
||||
const matrixRows = computed<MatrixRow[]>(() => {
|
||||
const rows: MatrixRow[] = []
|
||||
for (const r of matrixData.value.resources) {
|
||||
for (const a of matrixData.value.actions) {
|
||||
rows.push({ resource: r, action: a, key: `${r}:${a}` })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const filteredMatrixRows = computed(() => {
|
||||
if (!selectedResource.value) return matrixRows.value
|
||||
return matrixRows.value.filter((row) => row.resource === selectedResource.value)
|
||||
})
|
||||
|
||||
// ---- 方法 ----
|
||||
async function loadMatrix() {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await apiClient.get('/admin/roles/permissions/matrix')
|
||||
if (resp.data?.code === 0 && resp.data.data) {
|
||||
matrixData.value = resp.data.data
|
||||
} else {
|
||||
ElMessage.error('拉取权限矩阵失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('loadMatrix 失败:', e)
|
||||
ElMessage.error('加载失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拿到角色在 (resource, action) 下的所有 scope (own/department/all)。
|
||||
* 例: agent 看 conversation:read 时,可能有 own + all 两个 scope。
|
||||
*/
|
||||
function getScopesForRole(row: MatrixRow, roleName: string): string[] {
|
||||
const result: string[] = []
|
||||
for (const scope of matrixData.value.scopes) {
|
||||
const key = `${row.resource}:${row.action}:${scope}`
|
||||
if (matrixData.value.matrix[roleName]?.[key]) {
|
||||
result.push(scope)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getActionTagType(action: string): string {
|
||||
const types: Record<string, string> = {
|
||||
read: 'info',
|
||||
create: 'success',
|
||||
update: 'warning',
|
||||
delete: 'danger',
|
||||
}
|
||||
return types[action] || 'info'
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
// CSV 表头: 资源,操作, <每个角色>
|
||||
const header = ['resource', 'action', ...filteredRoles.value.map((r) => r.name)]
|
||||
const lines = [header.join(',')]
|
||||
|
||||
for (const row of filteredMatrixRows.value) {
|
||||
const cells = [row.resource, row.action]
|
||||
for (const role of filteredRoles.value) {
|
||||
const scopes = getScopesForRole(row, role.name)
|
||||
cells.push(scopes.join('|') || '')
|
||||
}
|
||||
lines.push(cells.join(','))
|
||||
}
|
||||
|
||||
const csv = lines.join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `rbac-matrix-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('CSV 已导出')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMatrix()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.matrix-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.legend-title {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.legend-item {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.legend-own {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.legend-dept {
|
||||
background: #93c5fd;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.legend-all {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
.legend-none {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.matrix-table {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.resource-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.role-header {
|
||||
text-align: center;
|
||||
}
|
||||
.role-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.role-id {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-family: monospace;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.perm-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.scope-tag {
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.scope-own {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
.scope-department {
|
||||
background: #93c5fd;
|
||||
color: #1e3a8a;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.scope-all {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
border-color: #1e3a8a;
|
||||
}
|
||||
.no-perm {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-detail {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.role-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.role-desc {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.role-perm-count {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user