权限监控与管理界面全面美化

重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效:
- 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式
- 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充
- 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮
- IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格
- 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片
- 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器
- 角色概览卡片:加进度条可视化,hover 微动效
- 接口权限矩阵:工具栏分离布局,表格圆角包裹
- 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 14:36:35 +08:00
parent e95eeed90e
commit 6cefa620e4
68 changed files with 6927 additions and 984 deletions
+26 -21
View File
@@ -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"');
});
});
+14 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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"');
});
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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) => {