前端权限管理:实现接口级权限管理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:
@@ -1,8 +1,47 @@
|
|||||||
import { apiGet, apiPut } from "./axios";
|
import { apiGet, apiPut, apiPost } from "./axios";
|
||||||
import type { ProjectRolePermissionsResponse, ProjectRolePermissionsUpdate } from "../types/api";
|
import type {
|
||||||
|
ProjectRolePermissionsResponse,
|
||||||
|
ProjectRolePermissionsUpdate,
|
||||||
|
ApiEndpointPermissionsResponse,
|
||||||
|
ApiEndpointPermissionsUpdate,
|
||||||
|
PermissionMetricsResponse,
|
||||||
|
CacheStatsResponse,
|
||||||
|
AlertsResponse,
|
||||||
|
HealthResponse,
|
||||||
|
} from "../types/api";
|
||||||
|
|
||||||
|
// 模块级权限(现有)
|
||||||
export const fetchProjectRolePermissions = (studyId: string) =>
|
export const fetchProjectRolePermissions = (studyId: string) =>
|
||||||
apiGet<ProjectRolePermissionsResponse>(`/api/v1/studies/${studyId}/permissions/`);
|
apiGet<ProjectRolePermissionsResponse>(`/api/v1/studies/${studyId}/permissions/`);
|
||||||
|
|
||||||
export const updateProjectRolePermissions = (studyId: string, payload: ProjectRolePermissionsUpdate) =>
|
export const updateProjectRolePermissions = (studyId: string, payload: ProjectRolePermissionsUpdate) =>
|
||||||
apiPut<ProjectRolePermissionsResponse>(`/api/v1/studies/${studyId}/permissions/`, payload);
|
apiPut<ProjectRolePermissionsResponse>(`/api/v1/studies/${studyId}/permissions/`, payload);
|
||||||
|
|
||||||
|
// 接口级权限(新增)
|
||||||
|
export const fetchApiEndpointPermissions = (studyId: string) =>
|
||||||
|
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`);
|
||||||
|
|
||||||
|
export const updateApiEndpointPermissions = (studyId: string, payload: ApiEndpointPermissionsUpdate) =>
|
||||||
|
apiPut<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, payload);
|
||||||
|
|
||||||
|
// 权限系统监控API(新增)
|
||||||
|
export const fetchPermissionMetrics = () =>
|
||||||
|
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`);
|
||||||
|
|
||||||
|
export const fetchCacheStats = () =>
|
||||||
|
apiGet<CacheStatsResponse>(`/api/v1/permission-monitoring/cache-stats`);
|
||||||
|
|
||||||
|
export const fetchPermissionAlerts = (level?: string, limit?: number) =>
|
||||||
|
apiGet<AlertsResponse>(`/api/v1/permission-monitoring/alerts`, {
|
||||||
|
params: { level, limit },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchPermissionHealth = () =>
|
||||||
|
apiGet<HealthResponse>(`/api/v1/permission-monitoring/health`);
|
||||||
|
|
||||||
|
export const resetPermissionMetrics = () =>
|
||||||
|
apiPost<void>(`/api/v1/permission-monitoring/reset-metrics`);
|
||||||
|
|
||||||
|
export const clearPermissionAlerts = () =>
|
||||||
|
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1150,5 +1150,42 @@ export const TEXT = {
|
|||||||
SITE: "中心",
|
SITE: "中心",
|
||||||
DERIVED: "派生",
|
DERIVED: "派生",
|
||||||
},
|
},
|
||||||
|
permissionManagement: {
|
||||||
|
title: "权限管理",
|
||||||
|
moduleLevel: "模块级权限",
|
||||||
|
apiLevel: "接口级权限",
|
||||||
|
monitoring: "权限监控",
|
||||||
|
refreshMetrics: "刷新指标",
|
||||||
|
save: "保存",
|
||||||
|
search: "搜索端点",
|
||||||
|
filterModule: "筛选模块",
|
||||||
|
filterMethod: "筛选方法",
|
||||||
|
endpoint: "端点",
|
||||||
|
module: "模块",
|
||||||
|
method: "方法",
|
||||||
|
role: "角色",
|
||||||
|
allowed: "允许",
|
||||||
|
denied: "拒绝",
|
||||||
|
healthStatus: "系统健康状态",
|
||||||
|
healthScore: "健康分数",
|
||||||
|
healthy: "健康",
|
||||||
|
degraded: "降级",
|
||||||
|
unhealthy: "不健康",
|
||||||
|
issues: "发现的问题",
|
||||||
|
totalChecks: "总检查次数",
|
||||||
|
avgResponseTime: "平均响应时间",
|
||||||
|
allowRate: "允许率",
|
||||||
|
errorRate: "错误率",
|
||||||
|
cacheEfficiency: "缓存效率",
|
||||||
|
cacheHitRate: "缓存命中率",
|
||||||
|
cacheItems: "缓存项目数",
|
||||||
|
cacheInvalidations: "缓存失效次数",
|
||||||
|
recentAlerts: "最近告警",
|
||||||
|
time: "时间",
|
||||||
|
level: "级别",
|
||||||
|
type: "类型",
|
||||||
|
message: "消息",
|
||||||
|
noAlerts: "暂无告警",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import ProjectMembers from "../views/admin/ProjectMembers.vue";
|
|||||||
import AdminSites from "../views/admin/Sites.vue";
|
import AdminSites from "../views/admin/Sites.vue";
|
||||||
import ProjectDetail from "../views/admin/ProjectDetail.vue";
|
import ProjectDetail from "../views/admin/ProjectDetail.vue";
|
||||||
import ProjectPermissions from "../views/admin/ProjectPermissions.vue";
|
import ProjectPermissions from "../views/admin/ProjectPermissions.vue";
|
||||||
|
import ApiPermissions from "../views/admin/ApiPermissions.vue";
|
||||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||||
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
||||||
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
||||||
@@ -447,6 +448,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: ProjectPermissions,
|
component: ProjectPermissions,
|
||||||
meta: { title: "权限管理", adminProjectPermission: { module: "project_members", action: "write" } },
|
meta: { title: "权限管理", adminProjectPermission: { module: "project_members", action: "write" } },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "projects/:id/api-permissions",
|
||||||
|
name: "AdminApiPermissions",
|
||||||
|
component: ApiPermissions,
|
||||||
|
meta: { title: "接口级权限管理", adminProjectPermission: { module: "project_members", action: "write" } },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "projects/:projectId/sites",
|
path: "projects/:projectId/sites",
|
||||||
name: "AdminSites",
|
name: "AdminSites",
|
||||||
|
|||||||
@@ -152,3 +152,78 @@ export interface ProjectRolePermissionsResponse {
|
|||||||
export interface ProjectRolePermissionsUpdate {
|
export interface ProjectRolePermissionsUpdate {
|
||||||
roles: ProjectPermissionMatrix;
|
roles: ProjectPermissionMatrix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 接口级权限
|
||||||
|
export interface ApiEndpointPermissionsResponse {
|
||||||
|
[role: string]: {
|
||||||
|
[endpoint_key: string]: {
|
||||||
|
allowed: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEndpointPermissionsUpdate {
|
||||||
|
[role: string]: {
|
||||||
|
[endpoint_key: string]: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监控指标
|
||||||
|
export interface PermissionCheckMetrics {
|
||||||
|
total_checks: number;
|
||||||
|
allowed_checks: number;
|
||||||
|
denied_checks: number;
|
||||||
|
total_time: number;
|
||||||
|
min_time: number;
|
||||||
|
max_time: number;
|
||||||
|
avg_time: number;
|
||||||
|
allow_rate: number;
|
||||||
|
deny_rate: number;
|
||||||
|
error_rate: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheMetrics {
|
||||||
|
total_accesses: number;
|
||||||
|
cache_hits: number;
|
||||||
|
cache_misses: number;
|
||||||
|
cache_invalidations: number;
|
||||||
|
hit_rate: number;
|
||||||
|
miss_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionMetricsResponse {
|
||||||
|
check_metrics: PermissionCheckMetrics;
|
||||||
|
cache_metrics: CacheMetrics;
|
||||||
|
uptime_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheStatsResponse {
|
||||||
|
cache_items: {
|
||||||
|
project_permissions_count: number;
|
||||||
|
member_role_count: number;
|
||||||
|
total_count: number;
|
||||||
|
};
|
||||||
|
cache_metrics: CacheMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
timestamp: number;
|
||||||
|
level: "info" | "warning" | "error";
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertsResponse {
|
||||||
|
total: number;
|
||||||
|
alerts: Alert[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
status: "healthy" | "degraded" | "unhealthy";
|
||||||
|
health_score: number;
|
||||||
|
issues: string[];
|
||||||
|
metrics: PermissionMetricsResponse;
|
||||||
|
cache_stats: CacheStatsResponse;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<div class="permission-page">
|
||||||
|
<div class="permission-shell unified-shell" v-loading="loading">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="permission-header unified-action-bar">
|
||||||
|
<div class="permission-title">
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
<h2>权限管理</h2>
|
||||||
|
</div>
|
||||||
|
<div class="permission-project-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-label">项目编号</span>
|
||||||
|
<strong>{{ project?.code || "-" }}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="meta-separator" />
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-label">项目名称</span>
|
||||||
|
<strong>{{ project?.name || "-" }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="permission-actions">
|
||||||
|
<el-button @click="resetMetrics" :loading="resetting">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
刷新指标
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
|
||||||
|
<el-icon><Check /></el-icon>
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签页:模块级权限 vs 接口级权限 -->
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<!-- 标签1:模块级权限(现有) -->
|
||||||
|
<el-tab-pane label="模块级权限" name="module">
|
||||||
|
<ProjectPermissionsModule
|
||||||
|
:project="project"
|
||||||
|
:matrix="moduleMatrix"
|
||||||
|
@update="onModuleMatrixUpdate"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 标签2:接口级权限(新增) -->
|
||||||
|
<el-tab-pane label="接口级权限" name="api">
|
||||||
|
<ApiEndpointPermissions
|
||||||
|
:project="project"
|
||||||
|
:matrix="apiMatrix"
|
||||||
|
@update="onApiMatrixUpdate"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 标签3:权限监控(新增) -->
|
||||||
|
<el-tab-pane label="权限监控" name="monitoring">
|
||||||
|
<PermissionMonitoring
|
||||||
|
:metrics="metrics"
|
||||||
|
:health="health"
|
||||||
|
:alerts="alerts"
|
||||||
|
@refresh="loadMonitoringData"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Key, RefreshRight, Check } from "@element-plus/icons-vue";
|
||||||
|
import type { Study } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
ProjectRolePermissionsResponse,
|
||||||
|
ApiEndpointPermissionsResponse,
|
||||||
|
PermissionMetricsResponse,
|
||||||
|
CacheStatsResponse,
|
||||||
|
AlertsResponse,
|
||||||
|
HealthResponse,
|
||||||
|
} from "@/types/api";
|
||||||
|
import {
|
||||||
|
fetchProjectRolePermissions,
|
||||||
|
updateProjectRolePermissions,
|
||||||
|
fetchApiEndpointPermissions,
|
||||||
|
updateApiEndpointPermissions,
|
||||||
|
fetchPermissionMetrics,
|
||||||
|
fetchCacheStats,
|
||||||
|
fetchPermissionAlerts,
|
||||||
|
fetchPermissionHealth,
|
||||||
|
resetPermissionMetrics,
|
||||||
|
} from "@/api/projectPermissions";
|
||||||
|
import { useStudyStore } from "@/store/study";
|
||||||
|
import ProjectPermissionsModule from "@/components/ProjectPermissionsModule.vue";
|
||||||
|
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||||
|
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const studyStore = useStudyStore();
|
||||||
|
|
||||||
|
const activeTab = ref<"module" | "api" | "monitoring">("module");
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const resetting = ref(false);
|
||||||
|
|
||||||
|
const project = computed(() => studyStore.currentStudy);
|
||||||
|
const studyId = computed(() => route.params.id as string);
|
||||||
|
|
||||||
|
// 权限数据
|
||||||
|
const moduleMatrix = ref<ProjectRolePermissionsResponse | null>(null);
|
||||||
|
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
|
||||||
|
|
||||||
|
// 监控数据
|
||||||
|
const metrics = ref<PermissionMetricsResponse | null>(null);
|
||||||
|
const health = ref<HealthResponse | null>(null);
|
||||||
|
const alerts = ref<AlertsResponse | null>(null);
|
||||||
|
|
||||||
|
// 脏值检测
|
||||||
|
const dirty = ref(false);
|
||||||
|
|
||||||
|
const loadPermissionData = async () => {
|
||||||
|
if (!studyId.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [moduleRes, apiRes] = await Promise.all([
|
||||||
|
fetchProjectRolePermissions(studyId.value),
|
||||||
|
fetchApiEndpointPermissions(studyId.value),
|
||||||
|
]);
|
||||||
|
|
||||||
|
moduleMatrix.value = moduleRes;
|
||||||
|
apiMatrix.value = apiRes;
|
||||||
|
dirty.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("加载权限数据失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMonitoringData = async () => {
|
||||||
|
if (!studyId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [metricsRes, healthRes, alertsRes] = await Promise.all([
|
||||||
|
fetchPermissionMetrics(),
|
||||||
|
fetchPermissionHealth(),
|
||||||
|
fetchPermissionAlerts(undefined, 20),
|
||||||
|
]);
|
||||||
|
|
||||||
|
metrics.value = metricsRes;
|
||||||
|
health.value = healthRes;
|
||||||
|
alerts.value = alertsRes;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("加载监控数据失败");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onModuleMatrixUpdate = (newMatrix: ProjectRolePermissionsResponse) => {
|
||||||
|
moduleMatrix.value = newMatrix;
|
||||||
|
dirty.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
|
||||||
|
apiMatrix.value = newMatrix;
|
||||||
|
dirty.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!studyId.value || !dirty.value) return;
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
// 保存当前活跃标签的权限
|
||||||
|
if (activeTab.value === "module" && moduleMatrix.value) {
|
||||||
|
await updateProjectRolePermissions(studyId.value, {
|
||||||
|
roles: moduleMatrix.value.roles,
|
||||||
|
});
|
||||||
|
} else if (activeTab.value === "api" && apiMatrix.value) {
|
||||||
|
await updateApiEndpointPermissions(studyId.value, apiMatrix.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty.value = false;
|
||||||
|
ElMessage.success("权限已保存");
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("保存权限失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetMetrics = async () => {
|
||||||
|
resetting.value = true;
|
||||||
|
try {
|
||||||
|
await resetPermissionMetrics();
|
||||||
|
await loadMonitoringData();
|
||||||
|
ElMessage.success("指标已重置");
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("重置指标失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
resetting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPermissionData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.permission-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-shell {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-project-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user