前端权限管理:实现接口级权限管理UI和监控仪表板

新增功能:
- 新增 ApiPermissions.vue 主页面,支持模块级/接口级权限切换
- 新增 ProjectPermissionsModule.vue 组件,展示模块级权限矩阵
- 新增 ApiEndpointPermissions.vue 组件,展示接口级权限矩阵
- 新增 PermissionMonitoring.vue 组件,展示权限系统监控仪表板
- 更新 projectPermissions.ts API 客户端,支持接口级权限和监控API
- 新增权限相关的 TypeScript 类型定义
- 更新路由配置,添加接口级权限管理路由
- 更新国际化文本,添加权限管理相关的中文文本

API 客户端新增函数:
- fetchApiEndpointPermissions: 获取接口级权限矩阵
- updateApiEndpointPermissions: 更新接口级权限矩阵
- fetchPermissionMetrics: 获取权限检查指标
- fetchCacheStats: 获取缓存统计
- fetchPermissionAlerts: 获取告警列表
- fetchPermissionHealth: 获取系统健康状态
- resetPermissionMetrics: 重置指标
- clearPermissionAlerts: 清除告警

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-14 09:19:07 +08:00
parent 1247b64e91
commit 387d035b08
8 changed files with 1107 additions and 2 deletions
@@ -0,0 +1,222 @@
<template>
<div class="api-permissions">
<!-- 搜索和筛选 -->
<div class="api-permissions-toolbar">
<el-input
v-model="searchText"
placeholder="搜索端点..."
clearable
style="width: 250px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
<el-option label="全部模块" value="" />
<el-option
v-for="module in uniqueModules"
:key="module"
:label="module"
:value="module"
/>
</el-select>
<el-select v-model="filterMethod" placeholder="筛选方法" clearable style="width: 120px">
<el-option label="全部方法" value="" />
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PATCH" value="PATCH" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</div>
<!-- 权限矩阵表格 -->
<div class="api-permissions-table-wrapper">
<el-table :data="filteredEndpoints" border stripe>
<el-table-column prop="endpoint_key" label="端点" width="250">
<template #default="{ row }">
<div class="endpoint-cell">
<el-tag :type="getMethodType(row.method)" size="small">
{{ row.method }}
</el-tag>
<span class="endpoint-path">{{ row.path }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="module" label="模块" width="100" />
<el-table-column
v-for="role in roles"
:key="role"
:label="role"
:width="100"
align="center"
>
<template #default="{ row }">
<el-checkbox
:model-value="isEndpointAllowed(row.endpoint_key, role)"
@change="(val) => onPermissionChange(row.endpoint_key, role, val as boolean)"
/>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { Search } from "@element-plus/icons-vue";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
interface Endpoint {
endpoint_key: string;
method: string;
path: string;
module: string;
}
interface Props {
project: any;
matrix: ApiEndpointPermissionsResponse | null;
}
interface Emits {
(e: "update", matrix: ApiEndpointPermissionsResponse): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const searchText = ref("");
const filterModule = ref("");
const filterMethod = ref("");
// 从矩阵中提取角色列表
const roles = computed(() => {
if (!props.matrix) return [];
return Object.keys(props.matrix).sort();
});
// 从矩阵中提取端点列表
const endpoints = computed(() => {
if (!props.matrix) return [];
const endpointSet = new Set<string>();
Object.values(props.matrix).forEach((rolePerms) => {
Object.keys(rolePerms).forEach((endpoint) => {
endpointSet.add(endpoint);
});
});
return Array.from(endpointSet)
.map((endpoint_key) => {
const [method, ...pathParts] = endpoint_key.split(":");
const path = pathParts.join(":");
const module = extractModule(path);
return {
endpoint_key,
method,
path,
module,
};
})
.sort((a, b) => a.endpoint_key.localeCompare(b.endpoint_key));
});
// 提取模块名称
const extractModule = (path: string): string => {
const match = path.match(/\/(\w+)/);
return match ? match[1] : "unknown";
};
// 获取唯一的模块列表
const uniqueModules = computed(() => {
return [...new Set(endpoints.value.map((e) => e.module))].sort();
});
// 过滤后的端点列表
const filteredEndpoints = computed(() => {
return endpoints.value.filter((endpoint) => {
const matchSearch =
!searchText.value ||
endpoint.endpoint_key.toLowerCase().includes(searchText.value.toLowerCase()) ||
endpoint.path.toLowerCase().includes(searchText.value.toLowerCase());
const matchModule = !filterModule.value || endpoint.module === filterModule.value;
const matchMethod = !filterMethod.value || endpoint.method === filterMethod.value;
return matchSearch && matchModule && matchMethod;
});
});
// 检查端点是否允许
const isEndpointAllowed = (endpoint_key: string, role: string): boolean => {
if (!props.matrix || !props.matrix[role]) return false;
return props.matrix[role][endpoint_key]?.allowed ?? false;
};
// 获取方法的标签类型
const getMethodType = (method: string): string => {
const typeMap: Record<string, string> = {
GET: "info",
POST: "success",
PATCH: "warning",
DELETE: "danger",
};
return typeMap[method] || "info";
};
// 权限变更处理
const onPermissionChange = (endpoint_key: string, role: string, allowed: boolean) => {
if (!props.matrix) return;
const updatedMatrix: ApiEndpointPermissionsResponse = JSON.parse(JSON.stringify(props.matrix));
if (!updatedMatrix[role]) {
updatedMatrix[role] = {};
}
updatedMatrix[role][endpoint_key] = { allowed };
emit("update", updatedMatrix);
};
</script>
<style scoped lang="scss">
.api-permissions {
padding: 20px 0;
}
.api-permissions-toolbar {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
}
.api-permissions-table-wrapper {
overflow-x: auto;
}
.endpoint-cell {
display: flex;
align-items: center;
gap: 10px;
:deep(.el-tag) {
min-width: 50px;
text-align: center;
}
}
.endpoint-path {
font-family: monospace;
font-size: 12px;
color: #606266;
}
</style>
@@ -0,0 +1,351 @@
<template>
<div class="permission-monitoring">
<!-- 系统健康评分 -->
<div v-if="health" class="health-card">
<div class="health-header">
<h3>系统健康状态</h3>
<el-tag :type="getHealthType(health.status)">
{{ getHealthLabel(health.status) }}
</el-tag>
</div>
<div class="health-score">
<div class="score-value">{{ health.health_score }}</div>
<div class="score-label">健康分数</div>
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div class="issues-title">发现的问题</div>
<ul>
<li v-for="(issue, index) in health.issues" :key="index">{{ issue }}</li>
</ul>
</div>
</div>
<!-- 性能指标卡片 -->
<div v-if="metrics" class="metrics-cards">
<div class="metric-card">
<div class="metric-label">总检查次数</div>
<div class="metric-value">{{ metrics.check_metrics.total_checks }}</div>
</div>
<div class="metric-card">
<div class="metric-label">平均响应时间</div>
<div class="metric-value">{{ (metrics.check_metrics.avg_time * 1000).toFixed(2) }}ms</div>
</div>
<div class="metric-card">
<div class="metric-label">允许率</div>
<div class="metric-value">{{ metrics.check_metrics.allow_rate.toFixed(1) }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">错误率</div>
<div class="metric-value" :style="{ color: metrics.check_metrics.error_rate > 1 ? '#f56c6c' : '#67c23a' }">
{{ metrics.check_metrics.error_rate.toFixed(2) }}%
</div>
</div>
</div>
<!-- 缓存效率 -->
<div v-if="metrics" class="cache-card">
<div class="cache-header">
<h3>缓存效率</h3>
</div>
<div class="cache-stats">
<div class="cache-stat">
<div class="stat-label">缓存命中率</div>
<el-progress
:percentage="metrics.cache_metrics.hit_rate"
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
/>
<div class="stat-value">{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%</div>
</div>
<div class="cache-stat">
<div class="stat-label">缓存项目数</div>
<div class="stat-value">{{ cacheStats?.cache_items.total_count || 0 }}</div>
</div>
<div class="cache-stat">
<div class="stat-label">缓存失效次数</div>
<div class="stat-value">{{ metrics.cache_metrics.cache_invalidations }}</div>
</div>
</div>
</div>
<!-- 告警列表 -->
<div class="alerts-card">
<div class="alerts-header">
<h3>最近告警</h3>
<el-button type="primary" size="small" @click="$emit('refresh')">
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</div>
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-table-wrapper">
<el-table :data="alerts.alerts" stripe border>
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="{ row }">
{{ formatTime(row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getAlertType(row.level)">{{ row.level }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="150" />
<el-table-column prop="message" label="消息" show-overflow-tooltip />
</el-table>
</div>
<div v-else class="empty-state">
<el-empty description="暂无告警" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { RefreshRight } from "@element-plus/icons-vue";
import type {
PermissionMetricsResponse,
CacheStatsResponse,
AlertsResponse,
HealthResponse,
} from "@/types/api";
interface Props {
metrics: PermissionMetricsResponse | null;
health: HealthResponse | null;
alerts: AlertsResponse | null;
}
interface Emits {
(e: "refresh"): void;
}
defineProps<Props>();
defineEmits<Emits>();
const cacheStats = computed(() => {
// 从 health 中提取 cache_stats
return null;
});
const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
const getHealthType = (status: string): string => {
const typeMap: Record<string, string> = {
healthy: "success",
degraded: "warning",
unhealthy: "danger",
};
return typeMap[status] || "info";
};
const getHealthLabel = (status: string): string => {
const labelMap: Record<string, string> = {
healthy: "健康",
degraded: "降级",
unhealthy: "不健康",
};
return labelMap[status] || status;
};
const getAlertType = (level: string): string => {
const typeMap: Record<string, string> = {
info: "info",
warning: "warning",
error: "danger",
};
return typeMap[level] || "info";
};
const getProgressColor = (percentage: number): string => {
if (percentage >= 80) return "#67c23a";
if (percentage >= 50) return "#e6a23c";
return "#f56c6c";
};
</script>
<style scoped lang="scss">
.permission-monitoring {
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.health-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
}
.health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.health-score {
text-align: center;
margin: 20px 0;
}
.score-value {
font-size: 48px;
font-weight: bold;
color: #409eff;
}
.score-label {
font-size: 14px;
color: #909399;
margin-top: 10px;
}
.health-issues {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dcdfe6;
}
.issues-title {
font-size: 14px;
font-weight: 600;
color: #606266;
margin-bottom: 10px;
}
.health-issues ul {
margin: 0;
padding-left: 20px;
li {
color: #f56c6c;
font-size: 13px;
margin: 5px 0;
}
}
.metrics-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.metric-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
text-align: center;
}
.metric-label {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.metric-value {
font-size: 24px;
font-weight: bold;
color: #303133;
}
.cache-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
}
.cache-header {
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.cache-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.cache-stat {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 15px;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #303133;
margin-top: 10px;
}
:deep(.el-progress) {
margin: 10px 0;
}
.alerts-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
}
.alerts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.alerts-table-wrapper {
overflow-x: auto;
}
.empty-state {
padding: 40px 20px;
text-align: center;
}
</style>
@@ -0,0 +1,95 @@
<template>
<div class="module-permissions">
<div class="permissions-table-wrapper">
<el-table :data="tableData" border stripe>
<el-table-column prop="role" label="角色" width="120" />
<el-table-column
v-for="module in modules"
:key="module.key"
:label="module.label"
:width="120"
>
<template #default="{ row }">
<div class="permission-cell">
<el-checkbox
v-model="row.permissions[module.key].read"
label="读"
size="small"
@change="onPermissionChange"
/>
<el-checkbox
v-if="module.writable !== false"
v-model="row.permissions[module.key].write"
label="写"
size="small"
@change="onPermissionChange"
/>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { ProjectRolePermissionsResponse, ProjectPermissionModule } from "@/types/api";
interface Props {
project: any;
matrix: ProjectRolePermissionsResponse | null;
}
interface Emits {
(e: "update", matrix: ProjectRolePermissionsResponse): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const modules = computed(() => {
if (!props.matrix) return [];
return props.matrix.modules || [];
});
const tableData = computed(() => {
if (!props.matrix) return [];
return Object.entries(props.matrix.roles).map(([role, permissions]) => ({
role,
permissions,
}));
});
const onPermissionChange = () => {
if (!props.matrix) return;
const updatedMatrix = {
...props.matrix,
roles: { ...props.matrix.roles },
};
emit("update", updatedMatrix);
};
</script>
<style scoped lang="scss">
.module-permissions {
padding: 20px 0;
}
.permissions-table-wrapper {
overflow-x: auto;
}
.permission-cell {
display: flex;
gap: 10px;
align-items: center;
:deep(.el-checkbox) {
margin: 0;
}
}
</style>