934d114a29
- store/study 与 router 改为读取 /api-permissions/me 拉取当前用户在 当前项目的有效权限,菜单与路由守卫据此判定,并在 PM 项目缺省时 主动 ensureDefaultPmStudy。 - api/projectPermissions 暴露 fetchMyApiEndpointPermissions,并允许 调用方关闭统一错误提示,便于在批量项目权限拉取场景下静默失败。 - 项目管理:Projects 页根据每个项目的有效权限决定项目入口的可点击 状态,缺权限时禁用项目名跳转;ProjectDetail 据 setup_config 写权限 渲染保存/回填/清空/发布按钮,并在无读取权限时提示并退出页面。 - 权限管理:PermissionManagement 切换为按角色提交,Admin 视角才能 编辑 PM 行;按系统模块美化系统级权限展示并标注 PM 可访问项;项目 成员表的角色下拉受 PM 等级约束,自定义角色入口下线。 - 监控:PermissionMonitoring/PermissionAccessLogs 增加 isAdmin 与 showSecurityLog 入参,仅 ADMIN 才能看到底层安全日志与统计。 - ApiEndpointPermissions 表格根据当前用户角色禁用不可写行,避免 误操作 PM 行;MODULE_LABELS 增加 setup_config 的中文展示。 - 测试同步覆盖以上行为:新版 router/Layout/Projects/PermissionManagement /PermissionAccessLogs/PermissionMonitoring/PermissionIpLocations 等 组件均补足检测,并按需为相关组件提供 pinia + localStorage stub。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
7.6 KiB
Vue
298 lines
7.6 KiB
Vue
<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>
|
|
|
|
<!-- 标签页 -->
|
|
<el-tabs v-model="activeTab">
|
|
<!-- 接口级权限 -->
|
|
<el-tab-pane label="角色权限" name="api">
|
|
<PermissionTemplateSelector
|
|
:study-id="studyId"
|
|
:current-permissions="currentPermissionsForTemplate"
|
|
@applied="onTemplateApplied"
|
|
/>
|
|
<el-divider />
|
|
<ApiEndpointPermissions
|
|
:project="project"
|
|
:matrix="apiMatrix"
|
|
@update="onApiMatrixUpdate"
|
|
/>
|
|
</el-tab-pane>
|
|
|
|
<!-- 权限监控 -->
|
|
<el-tab-pane label="权限监控" name="monitoring">
|
|
<PermissionMonitoring
|
|
:is-admin="isAdmin"
|
|
: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 {
|
|
ApiEndpointPermissionsResponse,
|
|
PermissionMetricsResponse,
|
|
CacheStatsResponse,
|
|
AlertsResponse,
|
|
HealthResponse,
|
|
} from "@/types/api";
|
|
import {
|
|
fetchApiEndpointPermissions,
|
|
updateApiEndpointPermissions,
|
|
fetchPermissionMetrics,
|
|
fetchCacheStats,
|
|
fetchPermissionAlerts,
|
|
fetchPermissionHealth,
|
|
resetPermissionMetrics,
|
|
} from "@/api/projectPermissions";
|
|
import { useStudyStore } from "@/store/study";
|
|
import { useAuthStore } from "@/store/auth";
|
|
import { isSystemAdmin } from "@/utils/roles";
|
|
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
|
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
|
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
|
|
|
const route = useRoute();
|
|
const studyStore = useStudyStore();
|
|
const auth = useAuthStore();
|
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
|
|
|
const activeTab = ref<"api" | "monitoring">("api");
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const resetting = ref(false);
|
|
|
|
const project = computed(() => studyStore.currentStudy);
|
|
const studyId = computed(() => {
|
|
const id = route.params.id;
|
|
return typeof id === "string" ? id : (id as string[])[0];
|
|
});
|
|
|
|
// 权限数据
|
|
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 apiRes = await fetchApiEndpointPermissions(studyId.value);
|
|
apiMatrix.value = apiRes.data;
|
|
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.data;
|
|
health.value = healthRes.data;
|
|
alerts.value = alertsRes.data;
|
|
} catch (error) {
|
|
ElMessage.error("加载监控数据失败");
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
|
|
apiMatrix.value = newMatrix;
|
|
dirty.value = true;
|
|
};
|
|
|
|
// 将当前 apiMatrix 转换为模板所需的 {role: {endpoint_key: bool}} 格式
|
|
const currentPermissionsForTemplate = computed(() => {
|
|
if (!apiMatrix.value) return undefined;
|
|
const result: Record<string, Record<string, boolean>> = {};
|
|
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
|
|
result[role] = {};
|
|
for (const [key, val] of Object.entries(endpoints)) {
|
|
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
|
}
|
|
}
|
|
return result;
|
|
});
|
|
|
|
const onTemplateApplied = async (permissions: Record<string, Record<string, { allowed: boolean }>>) => {
|
|
// 模板应用后刷新权限矩阵
|
|
await loadPermissionData();
|
|
ElMessage.success("权限已更新");
|
|
};
|
|
|
|
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
|
|
const result: Record<string, Record<string, boolean>> = {};
|
|
for (const [role, endpoints] of Object.entries(matrix)) {
|
|
result[role] = {};
|
|
for (const [key, val] of Object.entries(endpoints)) {
|
|
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!studyId.value || !dirty.value) return;
|
|
|
|
saving.value = true;
|
|
try {
|
|
if (apiMatrix.value) {
|
|
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
|
|
apiMatrix.value = res.data;
|
|
}
|
|
|
|
dirty.value = false;
|
|
ElMessage.success("权限已保存");
|
|
|
|
await loadPermissionData();
|
|
} 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>
|