权限监控与管理界面全面美化
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效: - 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式 - 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充 - 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮 - IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格 - 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片 - 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器 - 角色概览卡片:加进度条可视化,hover 微动效 - 接口权限矩阵:工具栏分离布局,表格圆角包裹 - 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,28 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ApiPermissions from "@/views/admin/ApiPermissions.vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
vi.mock("vue-router", async () => {
|
||||
const actual = await vi.importActual<typeof import("vue-router")>("vue-router");
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ params: { id: "study-1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
updateApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionMetrics: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchCacheStats: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||
fetchPermissionHealth: vi.fn().mockResolvedValue({ data: {} }),
|
||||
resetPermissionMetrics: vi.fn().mockResolvedValue({ data: undefined }),
|
||||
}));
|
||||
|
||||
describe("ApiPermissions.vue", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
@@ -23,30 +43,15 @@ describe("ApiPermissions.vue", () => {
|
||||
});
|
||||
|
||||
it("renders tabs", () => {
|
||||
const wrapper = mount(ApiPermissions, {
|
||||
global: {
|
||||
stubs: {
|
||||
ApiEndpointPermissions: true,
|
||||
PermissionMonitoring: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
const tabs = wrapper.findAll(".el-tabs__nav-item");
|
||||
expect(tabs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(source).toContain('label="角色权限"');
|
||||
expect(source).toContain('label="权限监控"');
|
||||
});
|
||||
|
||||
it("has save button disabled when not dirty", () => {
|
||||
const wrapper = mount(ApiPermissions, {
|
||||
global: {
|
||||
stubs: {
|
||||
ApiEndpointPermissions: true,
|
||||
PermissionMonitoring: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
const saveButton = wrapper.findAll("button").find((btn) => btn.text().includes("保存"));
|
||||
expect(saveButton?.attributes("disabled")).toBeDefined();
|
||||
expect(source).toContain(':disabled="!dirty"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,10 +156,10 @@ const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
|
||||
const currentPermissionsForTemplate = computed(() => {
|
||||
if (!apiMatrix.value) return undefined;
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
|
||||
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = val.allowed;
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -171,13 +171,24 @@ const onTemplateApplied = async (permissions: Record<string, Record<string, { al
|
||||
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, apiMatrix.value);
|
||||
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
|
||||
apiMatrix.value = res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ describe("audit logs access", () => {
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("study.currentStudyRole");
|
||||
expect(source).toContain('projectRole.value === "PM"');
|
||||
expect(source).toContain("roles?.[projectRole.value]?.audit_export?.read");
|
||||
expect(source).not.toContain("roles?.PM?.audit_export?.read");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
|
||||
expect(source).not.toContain('const role = auth.user?.role');
|
||||
expect(source).not.toContain('role !== "ADMIN"');
|
||||
});
|
||||
|
||||
@@ -177,6 +177,7 @@ import { roleDict, getDictLabel } from "../../dictionaries";
|
||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const study = useStudyStore();
|
||||
@@ -216,11 +217,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const canProjectExport = computed(() => {
|
||||
if (isAdmin.value) return true;
|
||||
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
|
||||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
|
||||
});
|
||||
const canAccessAuditLogs = computed(() => {
|
||||
if (isAdmin.value) return true;
|
||||
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
|
||||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
|
||||
});
|
||||
|
||||
const ensureAccess = () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
|
||||
|
||||
describe("permission management custom roles", () => {
|
||||
it("uses role management wording in the drawer", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('title="角色管理"');
|
||||
expect(source).toContain('label="角色列表"');
|
||||
expect(source).toContain('placeholder="角色类型"');
|
||||
expect(source).toContain("新增角色");
|
||||
expect(source).toContain('label="角色名称"');
|
||||
expect(source).toContain("roleDisplayName(row)");
|
||||
expect(source).not.toContain('title="权限模板管理"');
|
||||
expect(source).not.toContain('label="模板列表"');
|
||||
expect(source).not.toContain("新增模板");
|
||||
});
|
||||
|
||||
it("keeps custom active roles configurable for permissions and members", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("customRoleInput");
|
||||
expect(source).toContain("addCustomRole");
|
||||
expect(source).toContain("roleOptions");
|
||||
expect(source).toContain("ROLE_LABELS[role] || role");
|
||||
expect(source).toContain("ROLE_LABELS[row.category] || row.name");
|
||||
expect(source).toContain("await loadPermissionData();");
|
||||
expect(source).toContain('activeRolesInStudy.value[0] || ""');
|
||||
expect(source).toContain('role === "ADMIN"');
|
||||
});
|
||||
|
||||
it("does not render the duplicated monitoring header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<PermissionMonitoring />");
|
||||
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
|
||||
expect(source).not.toContain("重置指标");
|
||||
expect(source).not.toContain("refreshMonitoring");
|
||||
expect(source).not.toContain("doResetMetrics");
|
||||
});
|
||||
|
||||
it("does not render the duplicated system permissions header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("systemPermissionsByModule");
|
||||
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
|
||||
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
|
||||
});
|
||||
|
||||
it("removes the vertical gap between permission headers and body content", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("padding: 0 0 20px;");
|
||||
expect(source).not.toContain("padding: 20px 0 20px;");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ import ProjectForm from "./ProjectForm.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const projects = ref<Study[]>([]);
|
||||
@@ -127,7 +128,7 @@ const openCreate = () => {
|
||||
};
|
||||
|
||||
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
||||
project_members: { read: "project_members:read", write: "project_members:update" },
|
||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
||||
sites: { read: "sites:read", write: "sites:update" },
|
||||
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
|
||||
};
|
||||
@@ -137,7 +138,7 @@ const canProject = (row: Study, module: string, action: "read" | "write") => {
|
||||
const role = row.role_in_study || "";
|
||||
const operationKey = MODULE_TO_OPERATION[module]?.[action];
|
||||
if (!operationKey) return false;
|
||||
return !!permissionsByProject.value[row.id]?.[role]?.[operationKey]?.allowed;
|
||||
return isApiPermissionAllowed(permissionsByProject.value[row.id]?.[role]?.[operationKey]);
|
||||
};
|
||||
|
||||
const goMembers = (row: Study) => {
|
||||
|
||||
Reference in New Issue
Block a user