feat(perm/frontend): PM 视角的权限管理界面与个人有效权限接入
- store/study 与 router 改为读取 /api-permissions/me 拉取当前用户在 当前项目的有效权限,菜单与路由守卫据此判定,并在 PM 项目缺省时 主动 ensureDefaultPmStudy。 - api/projectPermissions 暴露 fetchMyApiEndpointPermissions,并允许 调用方关闭统一错误提示,便于在批量项目权限拉取场景下静默失败。 - 项目管理:Projects 页根据每个项目的有效权限决定项目入口的可点击 状态,缺权限时禁用项目名跳转;ProjectDetail 据 setup_config 写权限 渲染保存/回填/清空/发布按钮,并在无读取权限时提示并退出页面。 - 权限管理:PermissionManagement 切换为按角色提交,Admin 视角才能 编辑 PM 行;按系统模块美化系统级权限展示并标注 PM 可访问项;项目 成员表的角色下拉受 PM 等级约束,自定义角色入口下线。 - 监控:PermissionMonitoring/PermissionAccessLogs 增加 isAdmin 与 showSecurityLog 入参,仅 ADMIN 才能看到底层安全日志与统计。 - ApiEndpointPermissions 表格根据当前用户角色禁用不可写行,避免 误操作 PM 行;MODULE_LABELS 增加 setup_config 的中文展示。 - 测试同步覆盖以上行为:新版 router/Layout/Projects/PermissionManagement /PermissionAccessLogs/PermissionMonitoring/PermissionIpLocations 等 组件均补足检测,并按需为相关组件提供 pinia + localStorage stub。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,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: "物资设备",
|
||||||
|
|||||||
@@ -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!)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
@@ -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 } } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
/* ── 角色编辑器 ── */
|
/* ── 角色编辑器 ── */
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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"))');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user