From 934d114a296dc677c3da22d42c1e86898638e8bf Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 25 May 2026 12:38:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(perm/frontend):=20PM=20=E8=A7=86=E8=A7=92?= =?UTF-8?q?=E7=9A=84=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=E4=B8=AA=E4=BA=BA=E6=9C=89=E6=95=88=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/src/api/projectPermissions.ts | 9 +- .../components/ApiEndpointPermissions.test.ts | 20 +- .../src/components/ApiEndpointPermissions.vue | 12 + frontend/src/components/Layout.vue | 3 +- .../components/PermissionAccessLogs.test.ts | 3 + .../src/components/PermissionAccessLogs.vue | 13 +- .../components/PermissionIpLocations.test.ts | 6 +- .../components/PermissionMonitoring.test.ts | 9 + .../src/components/PermissionMonitoring.vue | 5 +- .../PermissionTemplateSelector.test.ts | 11 + frontend/src/router.test.ts | 9 + frontend/src/router/index.ts | 35 ++- frontend/src/store/study.test.ts | 27 ++ frontend/src/store/study.ts | 21 +- .../src/views/admin/ApiPermissions.test.ts | 13 + frontend/src/views/admin/ApiPermissions.vue | 5 + .../views/admin/PermissionManagement.test.ts | 67 ++++- .../src/views/admin/PermissionManagement.vue | 281 ++++++++++++------ frontend/src/views/admin/ProjectDetail.vue | 55 ++-- .../src/views/admin/ProjectMembers.test.ts | 2 +- frontend/src/views/admin/Projects.test.ts | 12 +- frontend/src/views/admin/Projects.vue | 28 +- 22 files changed, 501 insertions(+), 145 deletions(-) diff --git a/frontend/src/api/projectPermissions.ts b/frontend/src/api/projectPermissions.ts index 5b490869..5f9d2d46 100644 --- a/frontend/src/api/projectPermissions.ts +++ b/frontend/src/api/projectPermissions.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPut, apiPost, apiDelete } from "./axios"; +import { apiGet, apiPut, apiPost, apiDelete, type ApiRequestConfig } from "./axios"; import type { ApiEndpointPermissionsResponse, ApiEndpointPermissionsUpdate, @@ -15,8 +15,11 @@ import type { } from "../types/api"; // 接口级权限 -export const fetchApiEndpointPermissions = (studyId: string) => - apiGet(`/api/v1/studies/${studyId}/api-permissions`); +export const fetchApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) => + apiGet(`/api/v1/studies/${studyId}/api-permissions`, config); + +export const fetchMyApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) => + apiGet(`/api/v1/studies/${studyId}/api-permissions/me`, config); export const updateApiEndpointPermissions = (studyId: string, payload: ApiEndpointPermissionsUpdate) => apiPut(`/api/v1/studies/${studyId}/api-permissions`, payload); diff --git a/frontend/src/components/ApiEndpointPermissions.test.ts b/frontend/src/components/ApiEndpointPermissions.test.ts index 92557cd7..fe54c5d1 100644 --- a/frontend/src/components/ApiEndpointPermissions.test.ts +++ b/frontend/src/components/ApiEndpointPermissions.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; @@ -10,6 +11,23 @@ vi.mock("@/api/projectPermissions", () => ({ })); describe("ApiEndpointPermissions.vue", () => { + beforeEach(() => { + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + configurable: true, + }); + Object.defineProperty(globalThis, "localStorage", { + value: window.localStorage, + configurable: true, + }); + setActivePinia(createPinia()); + }); + const mockMatrix: ApiEndpointPermissionsResponse = { PM: { "POST:/subjects": true, diff --git a/frontend/src/components/ApiEndpointPermissions.vue b/frontend/src/components/ApiEndpointPermissions.vue index 35ba40b7..4164d1d5 100644 --- a/frontend/src/components/ApiEndpointPermissions.vue +++ b/frontend/src/components/ApiEndpointPermissions.vue @@ -62,6 +62,7 @@ @@ -78,6 +79,8 @@ import { ElMessage } from "element-plus"; import type { ApiEndpointPermissionsResponse } from "@/types/api"; import { fetchApiOperations } from "@/api/projectPermissions"; import { isApiPermissionAllowed } from "@/utils/apiPermissionValue"; +import { useAuthStore } from "@/store/auth"; +import { isSystemAdmin } from "@/utils/roles"; interface Operation { operation_key: string; @@ -99,6 +102,14 @@ interface Emits { const props = defineProps(); const emit = defineEmits(); +const auth = useAuthStore(); +const isAdmin = computed(() => isSystemAdmin(auth.user)); +const isRoleEditable = (role: string) => { + if (role === "ADMIN") return false; + if (isAdmin.value) return true; + return role !== "PM"; +}; + const searchText = ref(""); const filterModule = ref(""); const filterAction = ref(""); @@ -112,6 +123,7 @@ const MODULE_LABELS: Record = { project_members: "项目成员", project_milestones: "项目里程碑", project_overview: "项目总览", + setup_config: "立项配置", fees: "合同费用", materials: "物资管理", material_equipments: "物资设备", diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 2d0dca0b..4c2c8fa4 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -28,7 +28,7 @@ {{ TEXT.menu.auditLogs }} - + @@ -166,19 +171,31 @@ {{ systemPermissions.length }} 项权限 + + + {{ systemPermissions.filter(p => p.roles.includes('PM')).length }} 项 PM 可访问 +
-
- +
+ + + + + +
{{ systemModuleLabels[moduleKey] || moduleKey }} {{ items.length }} 项操作
+
+ 含 PM 权限 +
-
+
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }} @@ -187,7 +204,15 @@
{{ row.permission_key }}
- {{ ROLE_LABELS[r] || r }} + {{ roleLabel(r) }}
@@ -202,7 +227,7 @@ @@ -275,10 +300,6 @@ />
-
- - 新增角色 -
@@ -310,16 +331,16 @@
@@ -402,7 +423,7 @@ - + @@ -447,6 +468,7 @@ import { fetchStudies } from "@/api/studies"; import { useAuthStore } from "@/store/auth"; import { displayDateTime } from "@/utils/display"; import { isApiPermissionAllowed } from "@/utils/apiPermissionValue"; +import { isSystemAdmin } from "@/utils/roles"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import PermissionMonitoring from "@/components/PermissionMonitoring.vue"; import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue"; @@ -454,22 +476,43 @@ import PermissionTemplateSelector from "@/components/PermissionTemplateSelector. const route = useRoute(); const router = useRouter(); 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 = { PM: "项目负责人", CRA: "CRA", PV: "PV", MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA", }; +const roleLabel = (role: string) => ROLE_LABELS[role] || role; const activeRoleLabels = computed(() => { const result: Record = {}; for (const role of activeRolesInStudy.value) { - result[role] = ROLE_LABELS[role] || role; + result[role] = roleLabel(role); } return result; }); const ROLE_RANK: Record = { 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 = {}; + 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" => { @@ -513,7 +556,8 @@ const currentPermissionsForTemplate = computed(() => { const loadStudies = async () => { try { 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 { ElMessage.error("加载项目列表失败"); } @@ -534,6 +578,12 @@ const loadPermissionData = async () => { }; 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; dirty.value = false; members.value = []; @@ -549,9 +599,14 @@ const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.val const activeRolesInStudy = computed(() => activeRolesDraft.value); // 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式 -const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record> => { +const flattenMatrix = ( + matrix: ApiEndpointPermissionsResponse, + { includePm = true }: { includePm?: boolean } = {} +): Record> => { const result: Record> = {}; for (const [role, endpoints] of Object.entries(matrix as Record>)) { + if (role === "ADMIN") continue; + if (role === "PM" && !includePm) continue; result[role] = {}; for (const [key, val] of Object.entries(endpoints)) { result[role][key] = typeof val === "boolean" ? val : val.allowed; @@ -560,11 +615,21 @@ const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record +): Record> => ({ + [role]: { ...permissions }, +}); + const save = async () => { if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return; saving.value = true; 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; dirty.value = false; ElMessage.success("权限已保存"); @@ -588,10 +653,11 @@ const addMemberRules: FormRules = { role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }], }; -const canEditMember = (row: { user_id: string; role_in_study: string; user?: any }) => { - if (!auth.user?.is_admin) return false; +const canEditMember = (row: MemberRoleRow) => { + if (!canManageSelectedProject.value) return false; if (row.user_id === auth.user?.id) 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; }; @@ -633,7 +699,7 @@ const loadCandidates = async () => { const openAddMember = () => { 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; }; @@ -718,7 +784,6 @@ const ALL_ROLES = [ const activeRolesDraft = ref([]); const activeRolesLoading = ref(false); const activeRolesSaving = ref(false); -const customRoleInput = ref(""); const roleOptions = computed(() => { 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 () => { + if (!canManageSelectedProject.value) return; activeRolesSaving.value = true; try { await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value); @@ -799,11 +850,18 @@ const templateRules: FormRules = { const typeLabel = (t: string) => ({ ROLE: "预设角色", SCENARIO: "场景角色", CUSTOM: "自定义角色" }[t] ?? t); const typeTagType = (t: string) => ({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record)[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) => + Object.values(permissions).filter(Boolean).length; const countPermissions = (row: PermissionTemplate) => { + const role = row.category; + const currentRolePermissions = role ? currentPermissionsForTemplate.value?.[role] : undefined; + if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions); + let n = 0; for (const perms of Object.values(row.permissions) as Record[]) - n += Object.values(perms).filter(Boolean).length; + n += countEnabledPermissions(perms); return n; }; @@ -828,7 +886,7 @@ const openCreateTemplate = () => { const openEditTemplate = (row: PermissionTemplate) => { editingTemplateId.value = row.id; templateForm.value = { - name: row.name, description: row.description ?? "", + name: templateRoleName(row), description: row.description ?? "", template_type: row.template_type, category: row.category ?? "", recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "", }; @@ -934,7 +992,7 @@ const ROLE_EDITOR_MODULE_LABELS: Record = { subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题", monitoring_audit: "监查稽查", project_members: "项目成员", project_milestones: "项目里程碑", project_overview: "项目总览", - fees: "合同费用", materials: "物资管理", material_equipments: "物资设备", + setup_config: "立项配置", fees: "合同费用", materials: "物资管理", material_equipments: "物资设备", documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权", audit_export: "审计日志", faq: "FAQ", shared_library: "共享库", attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史", @@ -978,6 +1036,13 @@ const roleEditorGrouped = computed(() => { 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) => { editingRole.value = role; roleEditorSearch.value = ""; @@ -1022,19 +1087,19 @@ const roleEditorSelectAll = (val: boolean) => { const saveRoleEditor = async () => { if (!selectedStudyId.value || !apiMatrix.value) return; + if (!canEditSelectedRole.value) { + ElMessage.warning("当前角色权限不可修改"); + return; + } roleEditorSaving.value = true; try { - const payload = flattenMatrix(apiMatrix.value); - if (!payload[editingRole.value]) payload[editingRole.value] = {}; - for (const [key, val] of Object.entries(roleEditorDraft.value)) { - payload[editingRole.value][key] = val; - } + const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value); const res = await updateApiEndpointPermissions(selectedStudyId.value, payload); apiMatrix.value = res.data; dirty.value = false; const savedRole = editingRole.value; editingRole.value = ""; - ElMessage.success(`${ROLE_LABELS[savedRole] || savedRole} 权限已保存`); + ElMessage.success(`${roleLabel(savedRole)} 权限已保存`); } catch { ElMessage.error("保存失败"); } finally { @@ -1055,8 +1120,9 @@ onMounted(async () => { const qProjectId = route.query.projectId as string | undefined; const qSub = route.query.sub as string | undefined; - if (qProjectId) { - selectedStudyId.value = qProjectId; + const initialStudyId = qProjectId || studies.value[0]?.id; + if (initialStudyId) { + selectedStudyId.value = initialStudyId; if (qSub === "members") projectSubTab.value = "members"; await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]); loadCandidates(); @@ -1278,15 +1344,15 @@ onMounted(async () => { padding: 20px 28px; display: flex; flex-direction: column; - gap: 16px; + gap: 14px; } .system-module-block { background: #fff; - border-radius: 14px; - padding: 20px 24px; + border-radius: 12px; + padding: 16px 20px; 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; } @@ -1298,8 +1364,8 @@ onMounted(async () => { display: flex; align-items: center; gap: 12px; - margin-bottom: 16px; - padding-bottom: 14px; + margin-bottom: 12px; + padding-bottom: 12px; border-bottom: 1px solid #f1f5f9; } @@ -1307,67 +1373,103 @@ onMounted(async () => { display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; - border-radius: 10px; + width: 34px; + height: 34px; + border-radius: 9px; background: linear-gradient(135deg, #eff6ff, #e0f2fe); color: #3b82f6; 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 { - width: 18px; - height: 18px; + width: 17px; + height: 17px; } .system-module-info { display: flex; flex-direction: column; - gap: 2px; + gap: 1px; + flex: 1; } .system-module-title { - font-size: 15px; + font-size: 14px; font-weight: 600; color: #1a2332; } .system-module-count { font-size: 12px; - color: #64748b; + color: #94a3b8; +} + +.system-module-roles-summary { + flex-shrink: 0; } .system-perm-list { display: flex; flex-direction: column; - gap: 4px; + gap: 0; } .system-perm-row { display: grid; - grid-template-columns: minmax(200px, 1fr) minmax(180px, auto) auto; - gap: 16px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; align-items: center; - padding: 10px 12px; - border-radius: 8px; - transition: background 0.15s ease; + padding: 8px 10px; + border-radius: 7px; + transition: background 0.12s ease; +} + +.system-perm-row--alt { + background: #fafbfc; } .system-perm-row:hover { - background: #f8fafc; + background: #f1f5f9; } .system-perm-main { display: flex; align-items: center; - gap: 10px; + gap: 8px; min-width: 0; + justify-self: start; } .system-perm-action { flex-shrink: 0; - min-width: 44px; + min-width: 42px; text-align: center; + font-size: 11px; } .system-perm-desc { @@ -1382,7 +1484,8 @@ onMounted(async () => { display: flex; gap: 4px; flex-wrap: wrap; - justify-content: flex-end; + justify-content: flex-start; + justify-self: start; } .role-chip { @@ -1393,10 +1496,11 @@ onMounted(async () => { display: flex; align-items: center; gap: 12px; - padding: 8px 16px; - background: #f8fafc; + padding: 10px 16px; + background: #fff; border-radius: 10px; border: 1px solid #e2e8f0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); } .system-stat-item { @@ -1419,11 +1523,12 @@ onMounted(async () => { .system-perm-table { width: 100%; } .perm-key { + justify-self: start; font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; - font-size: 11.5px; - background: linear-gradient(135deg, #f0f4f8, #e8edf5); - padding: 3px 10px; - border-radius: 6px; + font-size: 11px; + background: #f1f5f9; + padding: 2px 8px; + border-radius: 5px; color: #475569; border: 1px solid #e2e8f0; white-space: nowrap; @@ -1506,16 +1611,6 @@ onMounted(async () => { 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; } /* ── 角色编辑器 ── */ diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index ae798141..193cbc04 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -56,10 +56,10 @@
- 保存配置 - 一键回填 - 清空草稿 - 发布版本 + 保存配置 + 一键回填 + 清空草稿 + 发布版本 ({ const storageKey = computed(() => `ctms_setup_config_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> | 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 isPublishedVersionView = computed(() => setupViewMode.value === "published"); const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value); @@ -4361,18 +4376,7 @@ const loadMemberDisplayMap = async (studyId: string) => { } }); } catch { - // Ignore member API errors and fallback to users API below. - } - 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 + // Ignore member API errors. } memberDisplayMap.value = nextMap; projectMemberOptions.value = Array.from(memberOptionMap.entries()) @@ -4384,8 +4388,21 @@ const loadProject = async () => { try { const projectId = route.params.projectId as string; 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; + projectPermissions.value = permRes.data as any; + + if (!hasSetupReadPermission.value) { + ElMessage.warning("当前角色无权访问立项配置"); + router.replace("/admin/projects"); + return; + } + syncFormFromProjectWithoutSetupLink(project.value); projectSnapshotAtLoad.value = buildProjectPublishSnapshot(); clearLocalProjectDraft(); diff --git a/frontend/src/views/admin/ProjectMembers.test.ts b/frontend/src/views/admin/ProjectMembers.test.ts index 7e821ac5..24c0943f 100644 --- a/frontend/src/views/admin/ProjectMembers.test.ts +++ b/frontend/src/views/admin/ProjectMembers.test.ts @@ -30,7 +30,7 @@ describe("ProjectMembers user directory access", () => { expect(source).toContain("const roleRank: Record"); 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(':disabled="!canAssignRole(\'ADMIN\')"'); expect(source).toContain(':disabled="!canEditMember(scope.row)'); diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index f9f69162..f663a797 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -42,12 +42,22 @@ describe("project management access", () => { it("uses each project permission matrix to show project management actions", () => { const source = readProjects(); - expect(source).toContain("fetchApiEndpointPermissions(item.id)"); + expect(source).toContain("fetchMyApiEndpointPermissions(item.id, { suppressErrorMessage: true })"); expect(source).toContain("permissionsByProject"); + expect(source).toContain("suppressErrorMessage: true"); 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', 'write')"); expect(source).toContain("canProject(scope.row, 'sites', '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"))'); + }); }); diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index 34307640..617668f1 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -74,7 +74,8 @@
- {{ scope.row.name }} + {{ scope.row.name }} + {{ scope.row.name }} {{ scope.row.code || '-' }}
@@ -151,7 +152,7 @@ import { useRouter } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document, Plus } from "@element-plus/icons-vue"; 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 ProjectForm from "./ProjectForm.vue"; import { useStudyStore } from "../../store/study"; @@ -184,7 +185,7 @@ const loadProjects = async () => { const entries = await Promise.all( items.map(async (item: Study) => { try { - const { data: permissions } = await fetchApiEndpointPermissions(item.id); + const { data: permissions } = await fetchMyApiEndpointPermissions(item.id, { suppressErrorMessage: true }); return [item.id, permissions] as const; } catch { return [item.id, null] as const; @@ -210,14 +211,18 @@ const MODULE_TO_OPERATION: Record> = { project_members: { read: "project_members:list", write: "project_members:update" }, sites: { read: "sites:read", write: "sites:update" }, audit_export: { read: "audit_logs:read", write: "audit_logs:read" }, + setup_config: { read: "setup_config:read", write: "setup_config:update" }, }; const canProject = (row: Study, module: string, action: "read" | "write") => { if (isAdmin.value) return true; - const role = row.role_in_study || ""; const operationKey = MODULE_TO_OPERATION[module]?.[action]; 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) => { @@ -330,11 +335,11 @@ const goDetail = (row: Study) => { }; const goProject = (row: Study) => { - if (isAdmin.value) { - goDetail(row); + if (!canProject(row, "setup_config", "write")) { + ElMessage.warning("当前角色无权访问该项目的立项配置"); return; } - enterStudy(row); + goDetail(row); }; const statusLabel = (status: string) => @@ -449,6 +454,13 @@ onMounted(() => { font-size: 13px; } +.project-name--disabled { + font-weight: 600; + font-size: 13px; + color: var(--ctms-text-secondary); + cursor: default; +} + .project-code { font-size: 12px; color: var(--ctms-text-secondary);