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:
Cheng Zhou
2026-05-25 12:38:54 +08:00
parent 747dd55225
commit 934d114a29
22 changed files with 501 additions and 145 deletions
+6 -3
View File
@@ -1,4 +1,4 @@
import { apiGet, apiPut, apiPost, apiDelete } from "./axios"; import { apiGet, apiPut, apiPost, apiDelete, type ApiRequestConfig } from "./axios";
import type { import type {
ApiEndpointPermissionsResponse, ApiEndpointPermissionsResponse,
ApiEndpointPermissionsUpdate, ApiEndpointPermissionsUpdate,
@@ -15,8 +15,11 @@ import type {
} from "../types/api"; } from "../types/api";
// 接口级权限 // 接口级权限
export const fetchApiEndpointPermissions = (studyId: string) => export const fetchApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`); apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, config);
export const fetchMyApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions/me`, config);
export const updateApiEndpointPermissions = (studyId: string, payload: ApiEndpointPermissionsUpdate) => export const updateApiEndpointPermissions = (studyId: string, payload: ApiEndpointPermissionsUpdate) =>
apiPut<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, payload); apiPut<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, payload);
@@ -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 { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
@@ -10,6 +11,23 @@ vi.mock("@/api/projectPermissions", () => ({
})); }));
describe("ApiEndpointPermissions.vue", () => { 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 = { const mockMatrix: ApiEndpointPermissionsResponse = {
PM: { PM: {
"POST:/subjects": true, "POST:/subjects": true,
@@ -62,6 +62,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-checkbox <el-checkbox
:model-value="isOperationAllowed(row.operation_key, role)" :model-value="isOperationAllowed(row.operation_key, role)"
:disabled="!isRoleEditable(role)"
@change="(val: boolean) => onPermissionChange(row.operation_key, role, val)" @change="(val: boolean) => onPermissionChange(row.operation_key, role, val)"
/> />
</template> </template>
@@ -78,6 +79,8 @@ import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api"; import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions"; import { fetchApiOperations } from "@/api/projectPermissions";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue"; import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { useAuthStore } from "@/store/auth";
import { isSystemAdmin } from "@/utils/roles";
interface Operation { interface Operation {
operation_key: string; operation_key: string;
@@ -99,6 +102,14 @@ interface Emits {
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<Emits>(); 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 searchText = ref("");
const filterModule = ref(""); const filterModule = ref("");
const filterAction = ref(""); const filterAction = ref("");
@@ -112,6 +123,7 @@ const MODULE_LABELS: Record<string, string> = {
project_members: "项目成员", project_members: "项目成员",
project_milestones: "项目里程碑", project_milestones: "项目里程碑",
project_overview: "项目总览", project_overview: "项目总览",
setup_config: "立项配置",
fees: "合同费用", fees: "合同费用",
materials: "物资管理", materials: "物资管理",
material_equipments: "物资设备", material_equipments: "物资设备",
+2 -1
View File
@@ -28,7 +28,7 @@
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>{{ TEXT.menu.auditLogs }}</span> <span>{{ TEXT.menu.auditLogs }}</span>
</el-menu-item> </el-menu-item>
<el-sub-menu v-if="isAdmin" index="permissions"> <el-sub-menu v-if="canAccessAdminPermissions" index="permissions">
<template #title> <template #title>
<el-icon><Key /></el-icon> <el-icon><Key /></el-icon>
<span>{{ TEXT.menu.permissionManagement }}</span> <span>{{ TEXT.menu.permissionManagement }}</span>
@@ -230,6 +230,7 @@ const route = useRoute();
const isAdmin = computed(() => !!auth.user?.is_admin); const isAdmin = computed(() => !!auth.user?.is_admin);
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1"); const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
const canAccessProjectPath = (path: string) => const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value); hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path)); 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", () => { it("renders low-level security access logs for anonymous and invalid requests", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("showSecurityLog");
expect(source).toContain('v-if="showSecurityLog"');
expect(source).toContain("fetchSecurityAccessLogs"); expect(source).toContain("fetchSecurityAccessLogs");
expect(source).toContain("安全事件审计日志"); expect(source).toContain("安全事件审计日志");
expect(source).toContain("securityTerminalLines"); expect(source).toContain("securityTerminalLines");
@@ -98,6 +100,7 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain("securityLogDialogVisible"); expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("openInterfaceLogDialog"); expect(source).toContain("openInterfaceLogDialog");
expect(source).toContain("openSecurityLogDialog"); expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("if (!showSecurityLog.value) return");
expect(source).toContain("downloadInterfaceLog"); expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("downloadSecurityLog"); expect(source).toContain("downloadSecurityLog");
expect(source).toContain("downloadLogFile"); expect(source).toContain("downloadLogFile");
@@ -89,7 +89,7 @@
</div> </div>
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} </el-tag> <el-tag effect="dark" size="small" round>{{ terminalLines.length }} </el-tag>
</div> </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"> <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> <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> </div>
@@ -159,6 +159,11 @@ import type {
} from "@/types/api"; } from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue"; 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" }, [ 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("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" }), h("circle", { cx: "12", cy: "12", r: "3" }),
@@ -368,6 +373,7 @@ const loadData = async (options: { silent?: boolean } = {}) => {
}; };
const loadSecurityData = async (options: { silent?: boolean } = {}) => { const loadSecurityData = async (options: { silent?: boolean } = {}) => {
if (!showSecurityLog.value) return;
if (!options.silent) securityLoading.value = true; if (!options.silent) securityLoading.value = true;
try { try {
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 }); const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
@@ -393,13 +399,13 @@ const startRealtimePolling = () => {
stopRealtimePolling(); stopRealtimePolling();
realtimeTimer.value = window.setInterval(() => { realtimeTimer.value = window.setInterval(() => {
loadData({ silent: true }); loadData({ silent: true });
loadSecurityData({ silent: true }); if (showSecurityLog.value) loadSecurityData({ silent: true });
}, REALTIME_POLL_INTERVAL_MS); }, REALTIME_POLL_INTERVAL_MS);
}; };
const refresh = () => { const refresh = () => {
loadData(); loadData();
loadSecurityData(); if (showSecurityLog.value) loadSecurityData();
}; };
const openInterfaceLogDialog = () => { const openInterfaceLogDialog = () => {
@@ -409,6 +415,7 @@ const openInterfaceLogDialog = () => {
}; };
const openSecurityLogDialog = () => { const openSecurityLogDialog = () => {
if (!showSecurityLog.value) return;
securityLogDialogVisible.value = true; securityLogDialogVisible.value = true;
loadSecurityData({ silent: true }); loadSecurityData({ silent: true });
scrollSecurityTerminalToBottom(); scrollSecurityTerminalToBottom();
@@ -38,10 +38,12 @@ describe("PermissionIpLocations", () => {
expect(mapSource).not.toContain("provinceBoxes"); 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(); 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).toContain("roam: false");
expect(source).not.toContain("scaleLimit"); expect(source).not.toContain("scaleLimit");
}); });
@@ -1,4 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { mount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue"; import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
@@ -68,4 +70,11 @@ describe("PermissionMonitoring.vue", () => {
expect(typeof wrapper.vm.refresh).toBe("function"); 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>
<el-tab-pane label="访问日志" name="logs"> <el-tab-pane label="访问日志" name="logs">
<PermissionAccessLogs ref="accessLogsRef" /> <PermissionAccessLogs ref="accessLogsRef" :show-security-log="props.isAdmin" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="IP属地" name="ip-locations"> <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 activeTab = ref("overview");
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
isAdmin: false,
});
const metrics = ref<PermissionMetricsResponse | null>(null); const metrics = ref<PermissionMetricsResponse | null>(null);
const health = ref<HealthResponse | null>(null); const health = ref<HealthResponse | null>(null);
const alerts = ref<AlertsResponse | null>(null); const alerts = ref<AlertsResponse | null>(null);
@@ -16,4 +16,15 @@ describe("PermissionTemplateSelector", () => {
expect(source).not.toContain("inactive-label"); expect(source).not.toContain("inactive-label");
expect(source).not.toContain("edit-hint"); 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!)");
});
}); });
+9
View File
@@ -5,6 +5,15 @@ import { resolve } from "node:path";
const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8"); const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8");
describe("admin project route permissions", () => { describe("admin project route permissions", () => {
it("lets any authorized project role open the project management detail page", () => {
const source = readRouter();
expect(source).toContain('name: "AdminProjectDetail"');
expect(source).toContain("requiresProjectMember: true");
expect(source).not.toContain("meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true }");
expect(source).toContain("ensureRouteProjectMember(to, studyStore)");
});
it("allows project admin pages through matrix-authorized PMs instead of all project roles", () => { it("allows project admin pages through matrix-authorized PMs instead of all project roles", () => {
const source = readRouter(); const source = readRouter();
+31 -4
View File
@@ -426,7 +426,7 @@ const routes: RouteRecordRaw[] = [
path: "projects/:projectId", path: "projects/:projectId",
name: "AdminProjectDetail", name: "AdminProjectDetail",
component: ProjectDetail, component: ProjectDetail,
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true }, meta: { title: TEXT.modules.adminProjects.detailTitle, requiresProjectMember: true },
}, },
{ {
path: "projects/:projectId/members", path: "projects/:projectId/members",
@@ -456,19 +456,19 @@ const routes: RouteRecordRaw[] = [
path: "permissions/system", path: "permissions/system",
name: "AdminPermissionsSystem", name: "AdminPermissionsSystem",
component: PermissionManagement, component: PermissionManagement,
meta: { title: "系统级权限", requiresAdmin: true }, meta: { title: "系统级权限", requiresProjectPm: true },
}, },
{ {
path: "permissions/project", path: "permissions/project",
name: "AdminPermissionsProject", name: "AdminPermissionsProject",
component: PermissionManagement, component: PermissionManagement,
meta: { title: "项目权限配置", requiresAdmin: true }, meta: { title: "项目权限配置", requiresProjectPm: true },
}, },
{ {
path: "permissions/monitoring", path: "permissions/monitoring",
name: "AdminPermissionsMonitoring", name: "AdminPermissionsMonitoring",
component: PermissionManagement, component: PermissionManagement,
meta: { title: "权限监控", requiresAdmin: true }, meta: { title: "权限监控", requiresProjectPm: true },
}, },
], ],
}, },
@@ -514,6 +514,15 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]); return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
}; };
const ensureRouteProjectMember = async (to: any, studyStore: ReturnType<typeof useStudyStore>) => {
const routeProjectId = typeof to.params.projectId === "string" ? to.params.projectId : "";
if (!routeProjectId) return false;
const { data: project } = await fetchStudyDetail(routeProjectId);
studyStore.setCurrentStudy(project);
return true;
};
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore(); const auth = useAuthStore();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
@@ -574,6 +583,24 @@ router.beforeEach(async (to, _from, next) => {
next({ path: "/admin/projects" }); next({ path: "/admin/projects" });
return; return;
} }
if (to.meta.requiresProjectMember && !isAdmin) {
const allowed = await ensureRouteProjectMember(to, studyStore).catch(() => false);
if (!allowed) {
next({ path: "/admin/projects" });
return;
}
}
if (to.meta.requiresProjectPm && !isAdmin) {
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
if (!hasPmProject) {
await studyStore.ensureDefaultPmStudy().catch(() => {});
}
const hasProjectPmAccess = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
if (!hasProjectPmAccess) {
next({ path: "/admin/projects" });
return;
}
}
if (to.name === "AdminAuditLogs") { if (to.name === "AdminAuditLogs") {
if (studyStore.currentStudy && !studyStore.currentPermissions) { if (studyStore.currentStudy && !studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions().catch(() => {}); await studyStore.loadCurrentStudyPermissions().catch(() => {});
+27
View File
@@ -2,11 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia"; import { createPinia, setActivePinia } from "pinia";
const fetchStudies = vi.fn(); const fetchStudies = vi.fn();
const fetchMyApiEndpointPermissions = vi.fn();
vi.mock("../api/studies", () => ({ vi.mock("../api/studies", () => ({
fetchStudies, fetchStudies,
})); }));
vi.mock("../api/projectPermissions", () => ({
fetchMyApiEndpointPermissions,
}));
type StorageLike = { type StorageLike = {
getItem: (key: string) => string | null; getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void; setItem: (key: string, value: string) => void;
@@ -34,6 +39,7 @@ describe("study store startup rehydration", () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); setActivePinia(createPinia());
fetchStudies.mockReset(); fetchStudies.mockReset();
fetchMyApiEndpointPermissions.mockReset();
Object.defineProperty(window, "localStorage", { Object.defineProperty(window, "localStorage", {
value: createStorage(), value: createStorage(),
configurable: true, configurable: true,
@@ -133,4 +139,25 @@ describe("study store startup rehydration", () => {
expect(JSON.parse(window.localStorage.getItem("ctms_current_study") || "{}").role_in_study).toBe("CRA"); expect(JSON.parse(window.localStorage.getItem("ctms_current_study") || "{}").role_in_study).toBe("CRA");
expect(window.localStorage.getItem("ctms_current_study_role")).toBeNull(); expect(window.localStorage.getItem("ctms_current_study_role")).toBeNull();
}); });
it("loads current user effective permissions instead of the PM management matrix", async () => {
const { useStudyStore } = await import("./study");
const study = useStudyStore();
fetchMyApiEndpointPermissions.mockResolvedValue({
data: { CRA: { "sites:read": { allowed: true } } },
});
study.setCurrentStudy({
id: "study-c",
code: "C",
name: "项目 C",
status: "ACTIVE",
role_in_study: "CRA",
} as any);
await study.loadCurrentStudyPermissions();
expect(fetchMyApiEndpointPermissions).toHaveBeenCalledWith("study-c");
expect(study.currentPermissions).toEqual({ CRA: { "sites:read": { allowed: true } } });
});
}); });
+19 -2
View File
@@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { fetchStudies } from "../api/studies"; import { fetchStudies } from "../api/studies";
import { fetchApiEndpointPermissions } from "../api/projectPermissions"; import { fetchMyApiEndpointPermissions } from "../api/projectPermissions";
import type { Study } from "../types/api"; import type { Study } from "../types/api";
const STUDY_KEY = "ctms_current_study"; const STUDY_KEY = "ctms_current_study";
@@ -149,6 +149,22 @@ export const useStudyStore = defineStore("study", () => {
return null; return null;
}; };
const ensureDefaultPmStudy = async () => {
if ((currentStudy.value as any)?.role_in_study === "PM") return currentStudy.value;
try {
const { data } = await fetchStudies();
const items = (data as any).items || [];
const pmStudy = items.find((study: Study) => study.role_in_study === "PM");
if (pmStudy) {
setCurrentStudy(pmStudy as Study);
return pmStudy as Study;
}
} catch {
return null;
}
return null;
};
const clearCurrentStudy = () => { const clearCurrentStudy = () => {
currentStudy.value = null; currentStudy.value = null;
currentStudyRole.value = null; currentStudyRole.value = null;
@@ -165,7 +181,7 @@ export const useStudyStore = defineStore("study", () => {
currentPermissions.value = null; currentPermissions.value = null;
return null; return null;
} }
const { data } = await fetchApiEndpointPermissions(currentStudy.value.id); const { data } = await fetchMyApiEndpointPermissions(currentStudy.value.id);
currentPermissions.value = data; currentPermissions.value = data;
return data; return data;
}; };
@@ -230,6 +246,7 @@ export const useStudyStore = defineStore("study", () => {
loadCurrentStudy, loadCurrentStudy,
ensureDefaultStudy, ensureDefaultStudy,
ensureDefaultActiveStudy, ensureDefaultActiveStudy,
ensureDefaultPmStudy,
restoreStudyForUser, restoreStudyForUser,
rehydrateStudyForLastUser, rehydrateStudyForLastUser,
loadCurrentStudyPermissions, loadCurrentStudyPermissions,
@@ -25,6 +25,19 @@ vi.mock("@/api/projectPermissions", () => ({
describe("ApiPermissions.vue", () => { describe("ApiPermissions.vue", () => {
beforeEach(() => { 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()); setActivePinia(createPinia());
}); });
@@ -50,6 +50,7 @@
<!-- 权限监控 --> <!-- 权限监控 -->
<el-tab-pane label="权限监控" name="monitoring"> <el-tab-pane label="权限监控" name="monitoring">
<PermissionMonitoring <PermissionMonitoring
:is-admin="isAdmin"
:metrics="metrics" :metrics="metrics"
:health="health" :health="health"
:alerts="alerts" :alerts="alerts"
@@ -83,12 +84,16 @@ import {
resetPermissionMetrics, resetPermissionMetrics,
} from "@/api/projectPermissions"; } from "@/api/projectPermissions";
import { useStudyStore } from "@/store/study"; import { useStudyStore } from "@/store/study";
import { useAuthStore } from "@/store/auth";
import { isSystemAdmin } from "@/utils/roles";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue"; import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue"; import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
const route = useRoute(); const route = useRoute();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const activeTab = ref<"api" | "monitoring">("api"); const activeTab = ref<"api" | "monitoring">("api");
const loading = ref(false); const loading = ref(false);
@@ -19,23 +19,66 @@ describe("permission management custom roles", () => {
expect(source).not.toContain("新增模板"); expect(source).not.toContain("新增模板");
}); });
it("keeps custom active roles configurable for permissions and members", () => { it("keeps active roles configurable for permissions and members without inline role creation", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("customRoleInput"); expect(source).not.toContain("customRoleInput");
expect(source).toContain("addCustomRole"); expect(source).not.toContain("addCustomRole");
expect(source).not.toContain("custom-role-entry");
expect(source).not.toContain("输入自定义角色标识");
expect(source).toContain("roleOptions"); expect(source).toContain("roleOptions");
expect(source).toContain("ROLE_LABELS[role] || role"); expect(source).toContain("ROLE_LABELS[role] || role");
expect(source).toContain("ROLE_LABELS[row.category] || row.name"); expect(source).toContain("row.category ? roleLabel(row.category) : row.name");
expect(source).toContain("await loadPermissionData();"); expect(source).toContain("await loadPermissionData();");
expect(source).toContain('activeRolesInStudy.value[0] || ""'); expect(source).toContain('Object.keys(assignableRoleLabels.value)[0] || ""');
expect(source).toContain('role === "ADMIN"'); expect(source).toContain('role === "ADMIN"');
}); });
it("counts role list permissions from current project permissions when available", () => {
const source = readSource();
expect(source).toContain("const countEnabledPermissions");
expect(source).toContain("const currentRolePermissions = role ? currentPermissionsForTemplate.value?.[role] : undefined;");
expect(source).toContain("if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);");
});
it("uses canonical role labels when opening preset roles for editing", () => {
const source = readSource();
expect(source).toContain("const roleLabel = (role: string) => ROLE_LABELS[role] || role;");
expect(source).toContain("const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;");
expect(source).toContain("name: templateRoleName(row), description: row.description ?? \"\",");
expect(source).toContain("roleDisplayName(row)");
});
it("keeps member role selects bound to role keys used by the permission matrix", () => {
const source = readSource();
expect(source).toContain('v-for="(label, val) in memberRoleLabels(row)"');
expect(source).toContain("for (const role of activeRolesInStudy.value)");
expect(source).toContain("result[role] = roleLabel(role);");
expect(source).toContain("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });");
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
});
it("submits only the selected role from role editor saves", () => {
const source = readSource();
expect(source).toContain("const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);");
expect(source).toContain("const flattenRolePermissions");
expect(source).not.toContain("const payload = flattenMatrix(apiMatrix.value, { includePm: isAdmin.value });");
});
it("renders setup_config as a Chinese module name in the role editor", () => {
const source = readSource();
expect(source).toContain('setup_config: "立项配置"');
});
it("does not render the duplicated monitoring header", () => { it("does not render the duplicated monitoring header", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("<PermissionMonitoring />"); expect(source).toContain('<PermissionMonitoring :is-admin="isAdmin" />');
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警"); expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
expect(source).not.toContain("重置指标"); expect(source).not.toContain("重置指标");
expect(source).not.toContain("refreshMonitoring"); expect(source).not.toContain("refreshMonitoring");
@@ -56,4 +99,16 @@ describe("permission management custom roles", () => {
expect(source).toContain("padding: 0 0 20px;"); expect(source).toContain("padding: 0 0 20px;");
expect(source).not.toContain("padding: 20px 0 20px;"); expect(source).not.toContain("padding: 20px 0 20px;");
}); });
it("limits project PM member management to subordinate project roles", () => {
const source = readSource();
expect(source).toContain("const assignableRoleLabels = computed");
expect(source).toContain("type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };");
expect(source).toContain("const memberRoleLabels = (row: MemberRoleRow)");
expect(source).toContain("(ROLE_RANK[role] ?? 0) < ROLE_RANK.PM");
expect(source).toContain("(ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM");
expect(source).toContain('addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";');
expect(source).not.toContain('addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM"');
});
}); });
+188 -93
View File
@@ -103,7 +103,12 @@
@change="(val: string) => updateMemberRole(row.id, val)" @change="(val: string) => updateMemberRole(row.id, val)"
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
> >
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" /> <el-option
v-for="(label, val) in memberRoleLabels(row)"
:key="val"
:label="label"
:value="val"
/>
</el-select> </el-select>
</template> </template>
</el-table-column> </el-table-column>
@@ -166,19 +171,31 @@
<span class="system-stat-item"> <span class="system-stat-item">
<strong>{{ systemPermissions.length }}</strong> 项权限 <strong>{{ systemPermissions.length }}</strong> 项权限
</span> </span>
<span class="system-stat-divider" />
<span class="system-stat-item">
<strong>{{ systemPermissions.filter(p => p.roles.includes('PM')).length }}</strong> PM 可访问
</span>
</div> </div>
<div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block"> <div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
<div class="system-module-header"> <div class="system-module-header">
<div class="system-module-icon"> <div class="system-module-icon" :class="`module-icon--${moduleKey}`">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> <svg v-if="moduleKey === 'system_users'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
<svg v-else-if="moduleKey === 'system_projects'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<svg v-else-if="moduleKey === 'system_permissions'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<svg v-else-if="moduleKey === 'system_monitoring'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
<svg v-else-if="moduleKey === 'system_audit'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</div> </div>
<div class="system-module-info"> <div class="system-module-info">
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span> <span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
<span class="system-module-count">{{ items.length }} 项操作</span> <span class="system-module-count">{{ items.length }} 项操作</span>
</div> </div>
<div class="system-module-roles-summary">
<el-tag v-if="items.some(i => i.roles.includes('PM'))" size="small" type="warning" effect="plain" round> PM 权限</el-tag>
</div>
</div> </div>
<div class="system-perm-list"> <div class="system-perm-list">
<div v-for="row in items" :key="row.permission_key" class="system-perm-row"> <div v-for="(row, idx) in items" :key="row.permission_key" class="system-perm-row" :class="{ 'system-perm-row--alt': idx % 2 === 1 }">
<div class="system-perm-main"> <div class="system-perm-main">
<el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" size="small" class="system-perm-action"> <el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" size="small" class="system-perm-action">
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }} {{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }}
@@ -187,7 +204,15 @@
</div> </div>
<code class="perm-key">{{ row.permission_key }}</code> <code class="perm-key">{{ row.permission_key }}</code>
<div class="system-perm-roles"> <div class="system-perm-roles">
<el-tag v-for="r in row.roles" :key="r" size="small" effect="dark" round class="role-chip">{{ ROLE_LABELS[r] || r }}</el-tag> <el-tag
v-for="r in row.roles"
:key="r"
size="small"
:effect="r === 'ADMIN' ? 'dark' : 'plain'"
:type="r === 'ADMIN' ? '' : 'warning'"
round
class="role-chip"
>{{ roleLabel(r) }}</el-tag>
</div> </div>
</div> </div>
</div> </div>
@@ -202,7 +227,7 @@
<template v-else-if="activeTab === 'monitoring'"> <template v-else-if="activeTab === 'monitoring'">
<div class="perm-body"> <div class="perm-body">
<div class="perm-body-padded"> <div class="perm-body-padded">
<PermissionMonitoring /> <PermissionMonitoring :is-admin="isAdmin" />
</div> </div>
</div> </div>
</template> </template>
@@ -275,10 +300,6 @@
/> />
</div> </div>
</div> </div>
<div class="custom-role-entry">
<el-input v-model="customRoleInput" placeholder="输入自定义角色标识,如 DATA_MANAGER" maxlength="20" clearable />
<el-button @click="addCustomRole">新增角色</el-button>
</div>
<div class="drawer-footer"> <div class="drawer-footer">
<el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button> <el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button>
</div> </div>
@@ -310,16 +331,16 @@
<!-- 右侧权限编辑区 --> <!-- 右侧权限编辑区 -->
<div class="role-editor-content"> <div class="role-editor-content">
<template v-if="editingRole"> <template v-if="editingRole">
<div v-if="editingRole === 'PM'" class="role-editor-notice"> <div v-if="!canEditSelectedRole" class="role-editor-notice">
<el-alert title="项目负责人拥有全部权限不可修改" type="info" :closable="false" show-icon /> <el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
</div> </div>
<div class="role-editor-toolbar"> <div class="role-editor-toolbar">
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable style="width: 220px"> <el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable style="width: 220px">
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<div class="filter-spacer" /> <div class="filter-spacer" />
<el-button size="small" :disabled="editingRole === 'PM'" @click="roleEditorSelectAll(true)">全选</el-button> <el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(true)">全选</el-button>
<el-button size="small" :disabled="editingRole === 'PM'" @click="roleEditorSelectAll(false)">全不选</el-button> <el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(false)">全不选</el-button>
</div> </div>
<div class="role-editor-modules"> <div class="role-editor-modules">
<div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module"> <div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module">
@@ -331,7 +352,7 @@
<div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row"> <div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row">
<el-checkbox <el-checkbox
:model-value="roleEditorDraft[op.operation_key] ?? false" :model-value="roleEditorDraft[op.operation_key] ?? false"
:disabled="editingRole === 'PM'" :disabled="!canEditSelectedRole"
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v" @change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
/> />
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag> <el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
@@ -341,8 +362,8 @@
</div> </div>
</div> </div>
<div class="drawer-footer"> <div class="drawer-footer">
<el-button type="primary" :loading="roleEditorSaving" :disabled="editingRole === 'PM'" @click="saveRoleEditor"> <el-button type="primary" :loading="roleEditorSaving" :disabled="!canEditSelectedRole" @click="saveRoleEditor">
保存 {{ ROLE_LABELS[editingRole] }} 权限 保存 {{ roleLabel(editingRole) }} 权限
</el-button> </el-button>
</div> </div>
</template> </template>
@@ -402,7 +423,7 @@
</el-form-item> </el-form-item>
<el-form-item label="项目角色" prop="role_in_study"> <el-form-item label="项目角色" prop="role_in_study">
<el-select v-model="addMemberForm.role_in_study" style="width: 100%"> <el-select v-model="addMemberForm.role_in_study" style="width: 100%">
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" /> <el-option v-for="(label, val) in assignableRoleLabels" :key="val" :label="label" :value="val" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -447,6 +468,7 @@ import { fetchStudies } from "@/api/studies";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display"; import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue"; import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue"; import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue"; import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
@@ -454,22 +476,43 @@ import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || "");
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
PM: "项目负责人", CRA: "CRA", PV: "PV", PM: "项目负责人", CRA: "CRA", PV: "PV",
MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA", MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA",
}; };
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
const activeRoleLabels = computed(() => { const activeRoleLabels = computed(() => {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
for (const role of activeRolesInStudy.value) { for (const role of activeRolesInStudy.value) {
result[role] = ROLE_LABELS[role] || role; result[role] = roleLabel(role);
} }
return result; return result;
}); });
const ROLE_RANK: Record<string, number> = { const ROLE_RANK: Record<string, number> = {
ADMIN: 100, PM: 80, QA: 60, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40, ADMIN: 100, PM: 80, QA: 60, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
}; };
type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };
const canAssignProjectRole = (role: string) => isAdmin.value || (ROLE_RANK[role] ?? 0) < ROLE_RANK.PM;
const assignableRoleLabels = computed(() => {
const result: Record<string, string> = {};
for (const role of activeRolesInStudy.value) {
if (canAssignProjectRole(role)) result[role] = roleLabel(role);
}
return result;
});
const memberRoleLabels = (row: MemberRoleRow) => {
if (canEditMember(row)) return assignableRoleLabels.value;
return {
...assignableRoleLabels.value,
[row.role_in_study]: roleLabel(row.role_in_study),
};
};
// ── 顶层标签 ── // ── 顶层标签 ──
const tabFromPath = (): "project" | "system" | "monitoring" => { const tabFromPath = (): "project" | "system" | "monitoring" => {
@@ -513,7 +556,8 @@ const currentPermissionsForTemplate = computed(() => {
const loadStudies = async () => { const loadStudies = async () => {
try { try {
const res = await fetchStudies(); const res = await fetchStudies();
studies.value = res.data.items ?? []; const items = res.data.items ?? [];
studies.value = isAdmin.value ? items : items.filter((item: Study) => item.role_in_study === "PM");
} catch { } catch {
ElMessage.error("加载项目列表失败"); ElMessage.error("加载项目列表失败");
} }
@@ -534,6 +578,12 @@ const loadPermissionData = async () => {
}; };
const onStudyChange = async (id: string) => { const onStudyChange = async (id: string) => {
const study = studies.value.find((s) => s.id === id);
if (!isAdmin.value && study?.role_in_study !== "PM") {
ElMessage.error("仅项目 PM 可配置该项目");
selectedStudyId.value = "";
return;
}
apiMatrix.value = null; apiMatrix.value = null;
dirty.value = false; dirty.value = false;
members.value = []; members.value = [];
@@ -549,9 +599,14 @@ const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.val
const activeRolesInStudy = computed(() => activeRolesDraft.value); const activeRolesInStudy = computed(() => activeRolesDraft.value);
// 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式 // 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => { const flattenMatrix = (
matrix: ApiEndpointPermissionsResponse,
{ includePm = true }: { includePm?: boolean } = {}
): Record<string, Record<string, boolean>> => {
const result: Record<string, Record<string, boolean>> = {}; const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(matrix as Record<string, Record<string, any>>)) { for (const [role, endpoints] of Object.entries(matrix as Record<string, Record<string, any>>)) {
if (role === "ADMIN") continue;
if (role === "PM" && !includePm) continue;
result[role] = {}; result[role] = {};
for (const [key, val] of Object.entries(endpoints)) { for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = typeof val === "boolean" ? val : val.allowed; result[role][key] = typeof val === "boolean" ? val : val.allowed;
@@ -560,11 +615,21 @@ const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, R
return result; return result;
}; };
const flattenRolePermissions = (
role: string,
permissions: Record<string, boolean>
): Record<string, Record<string, boolean>> => ({
[role]: { ...permissions },
});
const save = async () => { const save = async () => {
if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return; if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return;
saving.value = true; saving.value = true;
try { try {
const res = await updateApiEndpointPermissions(selectedStudyId.value, flattenMatrix(apiMatrix.value)); const res = await updateApiEndpointPermissions(
selectedStudyId.value,
flattenMatrix(apiMatrix.value, { includePm: isAdmin.value })
);
apiMatrix.value = res.data; apiMatrix.value = res.data;
dirty.value = false; dirty.value = false;
ElMessage.success("权限已保存"); ElMessage.success("权限已保存");
@@ -588,10 +653,11 @@ const addMemberRules: FormRules = {
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }], role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
}; };
const canEditMember = (row: { user_id: string; role_in_study: string; user?: any }) => { const canEditMember = (row: MemberRoleRow) => {
if (!auth.user?.is_admin) return false; if (!canManageSelectedProject.value) return false;
if (row.user_id === auth.user?.id) return false; if (row.user_id === auth.user?.id) return false;
if (row.user?.is_admin) return false; if (row.user?.is_admin) return false;
if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false;
return true; return true;
}; };
@@ -633,7 +699,7 @@ const loadCandidates = async () => {
const openAddMember = () => { const openAddMember = () => {
addMemberForm.user_id = ""; addMemberForm.user_id = "";
addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM" : activeRolesInStudy.value[0] || ""; addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";
addMemberVisible.value = true; addMemberVisible.value = true;
}; };
@@ -718,7 +784,6 @@ const ALL_ROLES = [
const activeRolesDraft = ref<string[]>([]); const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false); const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false); const activeRolesSaving = ref(false);
const customRoleInput = ref("");
const roleOptions = computed(() => { const roleOptions = computed(() => {
const options = [...ALL_ROLES]; const options = [...ALL_ROLES];
@@ -759,22 +824,8 @@ const toggleActiveRole = (role: string, active: boolean) => {
} }
}; };
const addCustomRole = () => {
const role = customRoleInput.value.trim();
if (!role) return;
if (role === "ADMIN") {
ElMessage.warning("ADMIN 不能作为项目角色");
return;
}
if (role.length > 20) {
ElMessage.warning("项目角色长度不能超过20个字符");
return;
}
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
customRoleInput.value = "";
};
const saveActiveRoles = async () => { const saveActiveRoles = async () => {
if (!canManageSelectedProject.value) return;
activeRolesSaving.value = true; activeRolesSaving.value = true;
try { try {
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value); await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
@@ -799,11 +850,18 @@ const templateRules: FormRules = {
const typeLabel = (t: string) => ({ ROLE: "预设角色", SCENARIO: "场景角色", CUSTOM: "自定义角色" }[t] ?? t); const typeLabel = (t: string) => ({ ROLE: "预设角色", SCENARIO: "场景角色", CUSTOM: "自定义角色" }[t] ?? t);
const typeTagType = (t: string) => const typeTagType = (t: string) =>
({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record<string, any>)[t] ?? "info"; ({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record<string, any>)[t] ?? "info";
const roleDisplayName = (row: PermissionTemplate) => (row.category ? (ROLE_LABELS[row.category] || row.name) : row.name); const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;
const roleDisplayName = (row: PermissionTemplate) => templateRoleName(row);
const countEnabledPermissions = (permissions: Record<string, boolean>) =>
Object.values(permissions).filter(Boolean).length;
const countPermissions = (row: PermissionTemplate) => { const countPermissions = (row: PermissionTemplate) => {
const role = row.category;
const currentRolePermissions = role ? currentPermissionsForTemplate.value?.[role] : undefined;
if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);
let n = 0; let n = 0;
for (const perms of Object.values(row.permissions) as Record<string, boolean>[]) for (const perms of Object.values(row.permissions) as Record<string, boolean>[])
n += Object.values(perms).filter(Boolean).length; n += countEnabledPermissions(perms);
return n; return n;
}; };
@@ -828,7 +886,7 @@ const openCreateTemplate = () => {
const openEditTemplate = (row: PermissionTemplate) => { const openEditTemplate = (row: PermissionTemplate) => {
editingTemplateId.value = row.id; editingTemplateId.value = row.id;
templateForm.value = { templateForm.value = {
name: row.name, description: row.description ?? "", name: templateRoleName(row), description: row.description ?? "",
template_type: row.template_type, category: row.category ?? "", template_type: row.template_type, category: row.category ?? "",
recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "", recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "",
}; };
@@ -934,7 +992,7 @@ const ROLE_EDITOR_MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题", subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题",
monitoring_audit: "监查稽查", project_members: "项目成员", monitoring_audit: "监查稽查", project_members: "项目成员",
project_milestones: "项目里程碑", project_overview: "项目总览", project_milestones: "项目里程碑", project_overview: "项目总览",
fees: "合同费用", materials: "物资管理", material_equipments: "物资设备", setup_config: "立项配置", fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权", documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权",
audit_export: "审计日志", faq: "FAQ", shared_library: "共享库", audit_export: "审计日志", faq: "FAQ", shared_library: "共享库",
attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史", attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史",
@@ -978,6 +1036,13 @@ const roleEditorGrouped = computed(() => {
return map; return map;
}); });
const canEditSelectedRole = computed(() => {
if (editingRole.value === "ADMIN") return false;
if (isAdmin.value) return true;
if (editingRole.value === "PM") return false;
return true;
});
const selectRoleForEdit = (role: string) => { const selectRoleForEdit = (role: string) => {
editingRole.value = role; editingRole.value = role;
roleEditorSearch.value = ""; roleEditorSearch.value = "";
@@ -1022,19 +1087,19 @@ const roleEditorSelectAll = (val: boolean) => {
const saveRoleEditor = async () => { const saveRoleEditor = async () => {
if (!selectedStudyId.value || !apiMatrix.value) return; if (!selectedStudyId.value || !apiMatrix.value) return;
if (!canEditSelectedRole.value) {
ElMessage.warning("当前角色权限不可修改");
return;
}
roleEditorSaving.value = true; roleEditorSaving.value = true;
try { try {
const payload = flattenMatrix(apiMatrix.value); const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);
if (!payload[editingRole.value]) payload[editingRole.value] = {};
for (const [key, val] of Object.entries(roleEditorDraft.value)) {
payload[editingRole.value][key] = val;
}
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload); const res = await updateApiEndpointPermissions(selectedStudyId.value, payload);
apiMatrix.value = res.data; apiMatrix.value = res.data;
dirty.value = false; dirty.value = false;
const savedRole = editingRole.value; const savedRole = editingRole.value;
editingRole.value = ""; editingRole.value = "";
ElMessage.success(`${ROLE_LABELS[savedRole] || savedRole} 权限已保存`); ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
} catch { } catch {
ElMessage.error("保存失败"); ElMessage.error("保存失败");
} finally { } finally {
@@ -1055,8 +1120,9 @@ onMounted(async () => {
const qProjectId = route.query.projectId as string | undefined; const qProjectId = route.query.projectId as string | undefined;
const qSub = route.query.sub as string | undefined; const qSub = route.query.sub as string | undefined;
if (qProjectId) { const initialStudyId = qProjectId || studies.value[0]?.id;
selectedStudyId.value = qProjectId; if (initialStudyId) {
selectedStudyId.value = initialStudyId;
if (qSub === "members") projectSubTab.value = "members"; if (qSub === "members") projectSubTab.value = "members";
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]); await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
loadCandidates(); loadCandidates();
@@ -1278,15 +1344,15 @@ onMounted(async () => {
padding: 20px 28px; padding: 20px 28px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 14px;
} }
.system-module-block { .system-module-block {
background: #fff; background: #fff;
border-radius: 14px; border-radius: 12px;
padding: 20px 24px; padding: 16px 20px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.02); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
} }
@@ -1298,8 +1364,8 @@ onMounted(async () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 16px; margin-bottom: 12px;
padding-bottom: 14px; padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
} }
@@ -1307,67 +1373,103 @@ onMounted(async () => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; width: 34px;
height: 36px; height: 34px;
border-radius: 10px; border-radius: 9px;
background: linear-gradient(135deg, #eff6ff, #e0f2fe); background: linear-gradient(135deg, #eff6ff, #e0f2fe);
color: #3b82f6; color: #3b82f6;
flex-shrink: 0; flex-shrink: 0;
} }
.system-module-icon.module-icon--system_users {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #d97706;
}
.system-module-icon.module-icon--system_projects {
background: linear-gradient(135deg, #ede9fe, #ddd6fe);
color: #7c3aed;
}
.system-module-icon.module-icon--system_permissions {
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
color: #059669;
}
.system-module-icon.module-icon--system_monitoring {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
color: #2563eb;
}
.system-module-icon.module-icon--system_audit {
background: linear-gradient(135deg, #fef2f2, #fecaca);
color: #dc2626;
}
.system-module-icon svg { .system-module-icon svg {
width: 18px; width: 17px;
height: 18px; height: 17px;
} }
.system-module-info { .system-module-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
flex: 1;
} }
.system-module-title { .system-module-title {
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1a2332; color: #1a2332;
} }
.system-module-count { .system-module-count {
font-size: 12px; font-size: 12px;
color: #64748b; color: #94a3b8;
}
.system-module-roles-summary {
flex-shrink: 0;
} }
.system-perm-list { .system-perm-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 0;
} }
.system-perm-row { .system-perm-row {
display: grid; display: grid;
grid-template-columns: minmax(200px, 1fr) minmax(180px, auto) auto; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px; gap: 12px;
align-items: center; align-items: center;
padding: 10px 12px; padding: 8px 10px;
border-radius: 8px; border-radius: 7px;
transition: background 0.15s ease; transition: background 0.12s ease;
}
.system-perm-row--alt {
background: #fafbfc;
} }
.system-perm-row:hover { .system-perm-row:hover {
background: #f8fafc; background: #f1f5f9;
} }
.system-perm-main { .system-perm-main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
min-width: 0; min-width: 0;
justify-self: start;
} }
.system-perm-action { .system-perm-action {
flex-shrink: 0; flex-shrink: 0;
min-width: 44px; min-width: 42px;
text-align: center; text-align: center;
font-size: 11px;
} }
.system-perm-desc { .system-perm-desc {
@@ -1382,7 +1484,8 @@ onMounted(async () => {
display: flex; display: flex;
gap: 4px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-start;
justify-self: start;
} }
.role-chip { .role-chip {
@@ -1393,10 +1496,11 @@ onMounted(async () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 8px 16px; padding: 10px 16px;
background: #f8fafc; background: #fff;
border-radius: 10px; border-radius: 10px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
} }
.system-stat-item { .system-stat-item {
@@ -1419,11 +1523,12 @@ onMounted(async () => {
.system-perm-table { width: 100%; } .system-perm-table { width: 100%; }
.perm-key { .perm-key {
justify-self: start;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 11.5px; font-size: 11px;
background: linear-gradient(135deg, #f0f4f8, #e8edf5); background: #f1f5f9;
padding: 3px 10px; padding: 2px 8px;
border-radius: 6px; border-radius: 5px;
color: #475569; color: #475569;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
white-space: nowrap; white-space: nowrap;
@@ -1506,16 +1611,6 @@ onMounted(async () => {
margin-top: 2px; margin-top: 2px;
} }
.custom-role-entry {
display: flex;
gap: 8px;
margin-top: 16px;
padding: 12px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
}
.template-name { font-weight: 600; color: #1a2332; } .template-name { font-weight: 600; color: #1a2332; }
/* ── 角色编辑器 ── */ /* ── 角色编辑器 ── */
+36 -19
View File
@@ -56,10 +56,10 @@
</div> </div>
<div class="setup-toolbar-actions"> <div class="setup-toolbar-actions">
<el-button type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button> <el-button v-if="canManageSetup" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button> <el-button v-if="canManageSetup" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
<el-button type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button> <el-button v-if="canManageSetup" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
<el-button type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button> <el-button v-if="canManageSetup" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
<el-button <el-button
type="info" type="info"
plain plain
@@ -1912,9 +1912,9 @@ import { ArrowLeft, ArrowRight, Promotion, User, OfficeBuilding, Lock, SuccessFi
import { import {
fetchStudyDetail, fetchStudyDetail,
} from "../../api/studies"; } from "../../api/studies";
import { fetchMyApiEndpointPermissions } from "../../api/projectPermissions";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import type { Site, Study } from "../../types/api"; import type { Site, Study } from "../../types/api";
import type { import type {
CenterConfirmDraft, CenterConfirmDraft,
@@ -1933,6 +1933,7 @@ import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { TEXT, requiredMessage } from "../../locales"; import { TEXT, requiredMessage } from "../../locales";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator"; import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator";
import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows"; import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows";
import { import {
@@ -2403,7 +2404,21 @@ const setupDraft = reactive<SetupConfigDraft>({
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`); const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`); const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
const canManageSetup = computed(() => isSystemAdmin(authStore.user)); const projectPermissions = ref<Record<string, Record<string, any>> | null>(null);
const hasSetupReadPermission = computed(() => {
if (isSystemAdmin(authStore.user)) return true;
if (!projectPermissions.value) return false;
const rolePerms = Object.values(projectPermissions.value)[0];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
});
const canManageSetup = computed(() => {
if (isSystemAdmin(authStore.user)) return true;
if (!projectPermissions.value) return false;
const rolePerms = Object.values(projectPermissions.value)[0];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
});
const isPreviewView = computed(() => setupViewMode.value === "preview"); const isPreviewView = computed(() => setupViewMode.value === "preview");
const isPublishedVersionView = computed(() => setupViewMode.value === "published"); const isPublishedVersionView = computed(() => setupViewMode.value === "published");
const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value); const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value);
@@ -4361,18 +4376,7 @@ const loadMemberDisplayMap = async (studyId: string) => {
} }
}); });
} catch { } catch {
// Ignore member API errors and fallback to users API below. // Ignore member API errors.
}
try {
const { data } = await fetchUsers({ limit: 1000 });
const users = (data as any)?.items || [];
users.forEach((user: any) => {
const userId = String(user?.id || "").trim();
if (!userId || nextMap[userId]) return;
nextMap[userId] = user?.full_name || user?.username || user?.email || userId;
});
} catch {
// ignore
} }
memberDisplayMap.value = nextMap; memberDisplayMap.value = nextMap;
projectMemberOptions.value = Array.from(memberOptionMap.entries()) projectMemberOptions.value = Array.from(memberOptionMap.entries())
@@ -4384,8 +4388,21 @@ const loadProject = async () => {
try { try {
const projectId = route.params.projectId as string; const projectId = route.params.projectId as string;
projectPublishCompareFallback.value = null; projectPublishCompareFallback.value = null;
const { data } = await fetchStudyDetail(projectId);
const [studyRes, permRes] = await Promise.all([
fetchStudyDetail(projectId),
fetchMyApiEndpointPermissions(projectId, { suppressErrorMessage: true }).catch(() => ({ data: null })),
]);
const { data } = studyRes;
project.value = data as Study; project.value = data as Study;
projectPermissions.value = permRes.data as any;
if (!hasSetupReadPermission.value) {
ElMessage.warning("当前角色无权访问立项配置");
router.replace("/admin/projects");
return;
}
syncFormFromProjectWithoutSetupLink(project.value); syncFormFromProjectWithoutSetupLink(project.value);
projectSnapshotAtLoad.value = buildProjectPublishSnapshot(); projectSnapshotAtLoad.value = buildProjectPublishSnapshot();
clearLocalProjectDraft(); clearLocalProjectDraft();
@@ -30,7 +30,7 @@ describe("ProjectMembers user directory access", () => {
expect(source).toContain("const roleRank: Record<string, number>"); expect(source).toContain("const roleRank: Record<string, number>");
expect(source).toContain("if (row.user_id === auth.user?.id) return false;"); expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
expect(source).toContain('if (row.user?.role === "ADMIN") return false;'); expect(source).toContain("if (row.user?.is_admin) return false;");
expect(source).toContain("const canAssignRole = (role: string)"); expect(source).toContain("const canAssignRole = (role: string)");
expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"'); expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"');
expect(source).toContain(':disabled="!canEditMember(scope.row)'); expect(source).toContain(':disabled="!canEditMember(scope.row)');
+11 -1
View File
@@ -42,12 +42,22 @@ describe("project management access", () => {
it("uses each project permission matrix to show project management actions", () => { it("uses each project permission matrix to show project management actions", () => {
const source = readProjects(); const source = readProjects();
expect(source).toContain("fetchApiEndpointPermissions(item.id)"); expect(source).toContain("fetchMyApiEndpointPermissions(item.id, { suppressErrorMessage: true })");
expect(source).toContain("permissionsByProject"); expect(source).toContain("permissionsByProject");
expect(source).toContain("suppressErrorMessage: true");
expect(source).toContain("const canProject = (row: Study, module: string, action: \"read\" | \"write\")"); expect(source).toContain("const canProject = (row: Study, module: string, action: \"read\" | \"write\")");
expect(source).toContain("canProject(scope.row, 'project_members', 'read')"); expect(source).toContain("canProject(scope.row, 'project_members', 'read')");
expect(source).toContain("canProject(scope.row, 'project_members', 'write')"); expect(source).toContain("canProject(scope.row, 'project_members', 'write')");
expect(source).toContain("canProject(scope.row, 'sites', 'read')"); expect(source).toContain("canProject(scope.row, 'sites', 'read')");
expect(source).toContain("canProject(scope.row, 'audit_export', 'read')"); expect(source).toContain("canProject(scope.row, 'audit_export', 'read')");
}); });
it("opens project names in management detail when the user has setup config write permission", () => {
const source = readProjects();
expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'write')\"");
expect(source).toContain('@click="goProject(scope.row)" class="project-name"');
expect(source).toContain('@click="enterStudy(scope.row)"');
expect(source).toContain('if (!canProject(row, "setup_config", "write"))');
});
}); });
+20 -8
View File
@@ -74,7 +74,8 @@
</svg> </svg>
</div> </div>
<div class="project-info"> <div class="project-info">
<el-link type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link> <el-link v-if="canProject(scope.row, 'setup_config', 'write')" type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
<span v-else class="project-name project-name--disabled">{{ scope.row.name }}</span>
<span class="project-code">{{ scope.row.code || '-' }}</span> <span class="project-code">{{ scope.row.code || '-' }}</span>
</div> </div>
</div> </div>
@@ -151,7 +152,7 @@ import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document, Plus } from "@element-plus/icons-vue"; import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document, Plus } from "@element-plus/icons-vue";
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies"; import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
import { fetchApiEndpointPermissions } from "../../api/projectPermissions"; import { fetchMyApiEndpointPermissions } from "../../api/projectPermissions";
import type { ApiEndpointPermissionsResponse, Study } from "../../types/api"; import type { ApiEndpointPermissionsResponse, Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue"; import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
@@ -184,7 +185,7 @@ const loadProjects = async () => {
const entries = await Promise.all( const entries = await Promise.all(
items.map(async (item: Study) => { items.map(async (item: Study) => {
try { try {
const { data: permissions } = await fetchApiEndpointPermissions(item.id); const { data: permissions } = await fetchMyApiEndpointPermissions(item.id, { suppressErrorMessage: true });
return [item.id, permissions] as const; return [item.id, permissions] as const;
} catch { } catch {
return [item.id, null] as const; return [item.id, null] as const;
@@ -210,14 +211,18 @@ const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
project_members: { read: "project_members:list", write: "project_members:update" }, project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" }, sites: { read: "sites:read", write: "sites:update" },
audit_export: { read: "audit_logs:read", write: "audit_logs:read" }, audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
setup_config: { read: "setup_config:read", write: "setup_config:update" },
}; };
const canProject = (row: Study, module: string, action: "read" | "write") => { const canProject = (row: Study, module: string, action: "read" | "write") => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
const role = row.role_in_study || "";
const operationKey = MODULE_TO_OPERATION[module]?.[action]; const operationKey = MODULE_TO_OPERATION[module]?.[action];
if (!operationKey) return false; if (!operationKey) return false;
return isApiPermissionAllowed(permissionsByProject.value[row.id]?.[role]?.[operationKey]); const perms = permissionsByProject.value[row.id];
if (!perms) return false;
const rolePerms = Object.values(perms)[0];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms[operationKey]);
}; };
const goMembers = (row: Study) => { const goMembers = (row: Study) => {
@@ -330,11 +335,11 @@ const goDetail = (row: Study) => {
}; };
const goProject = (row: Study) => { const goProject = (row: Study) => {
if (isAdmin.value) { if (!canProject(row, "setup_config", "write")) {
goDetail(row); ElMessage.warning("当前角色无权访问该项目的立项配置");
return; return;
} }
enterStudy(row); goDetail(row);
}; };
const statusLabel = (status: string) => const statusLabel = (status: string) =>
@@ -449,6 +454,13 @@ onMounted(() => {
font-size: 13px; font-size: 13px;
} }
.project-name--disabled {
font-weight: 600;
font-size: 13px;
color: var(--ctms-text-secondary);
cursor: default;
}
.project-code { .project-code {
font-size: 12px; font-size: 12px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);