feat(perm/frontend): PM 视角的权限管理界面与个人有效权限接入
- 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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
@@ -10,6 +11,23 @@ vi.mock("@/api/projectPermissions", () => ({
|
||||
}));
|
||||
|
||||
describe("ApiEndpointPermissions.vue", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: window.localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
const mockMatrix: ApiEndpointPermissionsResponse = {
|
||||
PM: {
|
||||
"POST:/subjects": true,
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<template #default="{ row }">
|
||||
<el-checkbox
|
||||
:model-value="isOperationAllowed(row.operation_key, role)"
|
||||
:disabled="!isRoleEditable(role)"
|
||||
@change="(val: boolean) => onPermissionChange(row.operation_key, role, val)"
|
||||
/>
|
||||
</template>
|
||||
@@ -78,6 +79,8 @@ import { ElMessage } from "element-plus";
|
||||
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
||||
import { fetchApiOperations } from "@/api/projectPermissions";
|
||||
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { isSystemAdmin } from "@/utils/roles";
|
||||
|
||||
interface Operation {
|
||||
operation_key: string;
|
||||
@@ -99,6 +102,14 @@ interface Emits {
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const isRoleEditable = (role: string) => {
|
||||
if (role === "ADMIN") return false;
|
||||
if (isAdmin.value) return true;
|
||||
return role !== "PM";
|
||||
};
|
||||
|
||||
const searchText = ref("");
|
||||
const filterModule = ref("");
|
||||
const filterAction = ref("");
|
||||
@@ -112,6 +123,7 @@ const MODULE_LABELS: Record<string, string> = {
|
||||
project_members: "项目成员",
|
||||
project_milestones: "项目里程碑",
|
||||
project_overview: "项目总览",
|
||||
setup_config: "立项配置",
|
||||
fees: "合同费用",
|
||||
materials: "物资管理",
|
||||
material_equipments: "物资设备",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ TEXT.menu.auditLogs }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu v-if="isAdmin" index="permissions">
|
||||
<el-sub-menu v-if="canAccessAdminPermissions" index="permissions">
|
||||
<template #title>
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>{{ TEXT.menu.permissionManagement }}</span>
|
||||
@@ -230,6 +230,7 @@ const route = useRoute();
|
||||
const isAdmin = computed(() => !!auth.user?.is_admin);
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
|
||||
const canAccessProjectPath = (path: string) =>
|
||||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||||
|
||||
@@ -78,6 +78,8 @@ describe("PermissionAccessLogs", () => {
|
||||
it("renders low-level security access logs for anonymous and invalid requests", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("showSecurityLog");
|
||||
expect(source).toContain('v-if="showSecurityLog"');
|
||||
expect(source).toContain("fetchSecurityAccessLogs");
|
||||
expect(source).toContain("安全事件审计日志");
|
||||
expect(source).toContain("securityTerminalLines");
|
||||
@@ -98,6 +100,7 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).toContain("securityLogDialogVisible");
|
||||
expect(source).toContain("openInterfaceLogDialog");
|
||||
expect(source).toContain("openSecurityLogDialog");
|
||||
expect(source).toContain("if (!showSecurityLog.value) return");
|
||||
expect(source).toContain("downloadInterfaceLog");
|
||||
expect(source).toContain("downloadSecurityLog");
|
||||
expect(source).toContain("downloadLogFile");
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} 条</el-tag>
|
||||
</div>
|
||||
<div class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
||||
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
||||
<div class="log-action-icon security">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
@@ -159,6 +159,11 @@ import type {
|
||||
} from "@/types/api";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
|
||||
showSecurityLog: false,
|
||||
});
|
||||
const showSecurityLog = computed(() => props.showSecurityLog);
|
||||
|
||||
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
|
||||
h("circle", { cx: "12", cy: "12", r: "3" }),
|
||||
@@ -368,6 +373,7 @@ const loadData = async (options: { silent?: boolean } = {}) => {
|
||||
};
|
||||
|
||||
const loadSecurityData = async (options: { silent?: boolean } = {}) => {
|
||||
if (!showSecurityLog.value) return;
|
||||
if (!options.silent) securityLoading.value = true;
|
||||
try {
|
||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
|
||||
@@ -393,13 +399,13 @@ const startRealtimePolling = () => {
|
||||
stopRealtimePolling();
|
||||
realtimeTimer.value = window.setInterval(() => {
|
||||
loadData({ silent: true });
|
||||
loadSecurityData({ silent: true });
|
||||
if (showSecurityLog.value) loadSecurityData({ silent: true });
|
||||
}, REALTIME_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
loadData();
|
||||
loadSecurityData();
|
||||
if (showSecurityLog.value) loadSecurityData();
|
||||
};
|
||||
|
||||
const openInterfaceLogDialog = () => {
|
||||
@@ -409,6 +415,7 @@ const openInterfaceLogDialog = () => {
|
||||
};
|
||||
|
||||
const openSecurityLogDialog = () => {
|
||||
if (!showSecurityLog.value) return;
|
||||
securityLogDialogVisible.value = true;
|
||||
loadSecurityData({ silent: true });
|
||||
scrollSecurityTerminalToBottom();
|
||||
|
||||
@@ -38,10 +38,12 @@ describe("PermissionIpLocations", () => {
|
||||
expect(mapSource).not.toContain("provinceBoxes");
|
||||
});
|
||||
|
||||
it("hides the map legend and disables map zoom interactions", () => {
|
||||
it("keeps the map legend and disables map zoom interactions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("visualMap:");
|
||||
expect(source).toContain("visualMap:");
|
||||
expect(source).toContain("VisualMapComponent");
|
||||
expect(source).toContain("max: maxValue.value");
|
||||
expect(source).toContain("roam: false");
|
||||
expect(source).not.toContain("scaleLimit");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
|
||||
@@ -68,4 +70,11 @@ describe("PermissionMonitoring.vue", () => {
|
||||
|
||||
expect(typeof wrapper.vm.refresh).toBe("function");
|
||||
});
|
||||
|
||||
it("passes admin-only access to security logs", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||
|
||||
expect(source).toContain("isAdmin?: boolean");
|
||||
expect(source).toContain(':show-security-log="props.isAdmin"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="访问日志" name="logs">
|
||||
<PermissionAccessLogs ref="accessLogsRef" />
|
||||
<PermissionAccessLogs ref="accessLogsRef" :show-security-log="props.isAdmin" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="IP属地" name="ip-locations">
|
||||
@@ -178,6 +178,9 @@ const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke
|
||||
]);
|
||||
|
||||
const activeTab = ref("overview");
|
||||
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
|
||||
isAdmin: false,
|
||||
});
|
||||
const metrics = ref<PermissionMetricsResponse | null>(null);
|
||||
const health = ref<HealthResponse | null>(null);
|
||||
const alerts = ref<AlertsResponse | null>(null);
|
||||
|
||||
@@ -16,4 +16,15 @@ describe("PermissionTemplateSelector", () => {
|
||||
expect(source).not.toContain("inactive-label");
|
||||
expect(source).not.toContain("edit-hint");
|
||||
});
|
||||
|
||||
it("reads current project permissions by role key, not by role label", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("template.category && props.currentPermissions[template.category]");
|
||||
expect(source).toContain("enabledPercent(template.category)");
|
||||
expect(source).toContain("countCurrentEnabled(template.category)");
|
||||
expect(source).toContain("countCurrentDisabled(template.category)");
|
||||
expect(source).toContain("const perms = props.currentPermissions?.[role];");
|
||||
expect(source).toContain("emit('edit-role', template.category!)");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user