From 30e23984007107e30328ef916a8ae3bda527d9c1 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 8 Jun 2026 11:08:23 +0800 Subject: [PATCH] fix(router): check system permissions against target project --- backend/app/api/v1/sites.py | 8 +- backend/tests/test_api_permissions.py | 11 ++ frontend/src/components/Layout.vue | 227 +++++++++++++++++----- frontend/src/router.test.ts | 8 +- frontend/src/router/index.ts | 28 ++- frontend/src/styles/unified-page.css | 2 +- frontend/src/views/admin/Projects.test.ts | 32 ++- frontend/src/views/admin/Projects.vue | 35 ++-- frontend/src/views/admin/Sites.vue | 14 +- 9 files changed, 283 insertions(+), 82 deletions(-) diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index b6639b87..d080d79b 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -12,7 +12,7 @@ from app.crud import startup as startup_crud from app.crud import study as study_crud from app.crud import user as user_crud from app.schemas.site import SiteCreate, SiteRead, SiteUpdate -from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate +from app.schemas.startup import KickoffMeetingCreate, StartupEthicsCreate, StartupFeasibilityCreate from app.services.site_contact_display import build_contact_display, parse_contact_user_ids router = APIRouter() @@ -58,6 +58,12 @@ async def create_site( StartupEthicsCreate(site_id=site.id), created_by=getattr(current_user, "id", None), ) + await startup_crud.create_kickoff( + db, + study_id, + KickoffMeetingCreate(site_id=site.id), + created_by=getattr(current_user, "id", None), + ) await audit_crud.log_action( db, study_id=study_id, diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index 5d312ec3..e8da4d92 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -413,6 +413,17 @@ def test_training_authorization_list_can_filter_by_site_name(): assert "TrainingAuthorization.site_name == site_name" in crud_source +def test_site_creation_initializes_all_startup_baseline_records(): + """创建中心时应生成启动模块默认记录,但后续手动删除不应依赖列表页重建。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "sites.py" + source = route_path.read_text() + + assert "StartupFeasibilityCreate(site_id=site.id)" in source + assert "StartupEthicsCreate(site_id=site.id)" in source + assert "KickoffMeetingCreate(site_id=site.id)" in source + assert "await startup_crud.create_kickoff(" in source + + def test_visit_list_permission_matches_wired_visit_endpoints(): """访视当前没有详情查询接口,列表接口应只使用 visits:list。""" visits_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "visits.py" diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 6a9505b3..12d72a87 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -193,10 +193,12 @@
- -
- -
+ + +
+ +
+
@@ -471,15 +473,15 @@ const onCommand = (cmd: string) => { background-color: #2c3e50; display: flex; flex-direction: column; - box-shadow: 0 0 12px rgba(0, 0, 0, 0.08); + box-shadow: 4px 0 18px rgba(15, 23, 42, 0.12); z-index: 10; border-right: none; - transition: width 0.18s ease; + transition: width 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms ease; overflow: hidden; } .aside-menu { - transition: padding 0.18s ease; + transition: padding 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .layout-aside.collapsed .logo-text { @@ -503,7 +505,7 @@ const onCommand = (cmd: string) => { align-items: center; padding: 0 14px; background-color: transparent; - transition: padding 0.18s ease; + transition: padding 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .logo-icon { @@ -512,7 +514,7 @@ const onCommand = (cmd: string) => { background: #ffffff; border-radius: 8px; margin-right: 12px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.26); } .logo-text { @@ -520,7 +522,7 @@ const onCommand = (cmd: string) => { font-size: 18px; font-weight: 800; letter-spacing: 0.5px; - transition: opacity 0.18s ease, transform 0.18s ease; + transition: opacity 180ms ease, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .aside-menu { @@ -551,18 +553,45 @@ const onCommand = (cmd: string) => { margin: 1px 8px; border-radius: 7px; color: #f1f5f9 !important; - transition: var(--ctms-transition); + position: relative; + overflow: hidden; + transform: translateX(0); + transition: + background-color 180ms ease, + color 180ms ease, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 180ms ease; + will-change: transform; } .layout-aside :deep(.el-menu-item:hover) { color: #ffffff !important; - background-color: rgba(255, 255, 255, 0.08) !important; + background-color: rgba(255, 255, 255, 0.09) !important; + transform: translateX(2px); +} + +.layout-aside :deep(.el-menu-item:active) { + transform: translateX(1px) scale(0.985); +} + +.layout-aside :deep(.el-menu-item .el-icon), +.layout-aside :deep(.el-sub-menu__title .el-icon) { + transition: + color 180ms ease, + opacity 180ms ease, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .layout-aside :deep(.el-menu-item.is-active) { color: #ffffff !important; - background-color: rgba(255, 255, 255, 0.12) !important; - box-shadow: none; + background-color: rgba(255, 255, 255, 0.14) !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 22px rgba(15, 23, 42, 0.14); +} + +.layout-aside :deep(.el-menu-item.is-active .el-icon) { + transform: translateX(1px) scale(1.04); } .layout-aside :deep(.el-menu-item.is-active::before) { @@ -574,6 +603,22 @@ const onCommand = (cmd: string) => { width: 3px; background-color: #94b8d1; border-radius: 0 4px 4px 0; + transform: scaleY(1); + transform-origin: center; + transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms ease; +} + +.layout-aside :deep(.el-menu-item:not(.is-active)::before) { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + background-color: #94b8d1; + border-radius: 0 4px 4px 0; + opacity: 0; + transform: scaleY(0.35); } .layout-aside :deep(.el-sub-menu__title) { @@ -582,37 +627,69 @@ const onCommand = (cmd: string) => { border-radius: 7px; height: 34px; line-height: 34px; + position: relative; + overflow: hidden; + transform: translateX(0); + transition: + background-color 180ms ease, + color 180ms ease, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 180ms ease; } .layout-aside :deep(.el-sub-menu__title:hover) { color: #ffffff !important; - background-color: rgba(255, 255, 255, 0.08) !important; + background-color: rgba(255, 255, 255, 0.09) !important; + transform: translateX(2px); +} + +.layout-aside :deep(.el-sub-menu__title:active) { + transform: translateX(1px) scale(0.985); } .layout-aside :deep(.el-sub-menu.is-active .el-sub-menu__title) { color: #ffffff !important; } +.layout-aside :deep(.el-sub-menu.is-active .el-sub-menu__title .el-icon) { + transform: translateX(1px) scale(1.04); +} + +.layout-aside :deep(.el-sub-menu__icon-arrow) { + transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + .layout-aside :deep(.el-menu--inline) { background-color: transparent !important; - margin: 0 !important; + margin: 2px 0 4px !important; } .layout-aside :deep(.el-menu--inline .el-menu-item) { padding-left: 40px !important; - margin: 0 10px; + margin: 1px 10px; + color: rgba(241, 245, 249, 0.88) !important; +} + +.layout-aside :deep(.el-menu--inline .el-menu-item:hover) { + color: #ffffff !important; +} + +.layout-aside :deep(.el-menu--inline .el-menu-item.is-active) { + color: #ffffff !important; } .layout-header { - background-color: #ffffff; + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(12px) saturate(160%); + -webkit-backdrop-filter: blur(12px) saturate(160%); display: flex; align-items: center; justify-content: space-between; - padding: 0 10px; - box-shadow: none; + padding: 0 16px; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04), 0 2px 8px rgba(0, 0, 0, 0.03); height: 52px !important; z-index: 9; - border-bottom: 1px solid #f1f5f9; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); } .main-container { @@ -622,10 +699,10 @@ const onCommand = (cmd: string) => { .header-breadcrumb { display: flex; align-items: center; - gap: 6px; + gap: 4px; color: var(--ctms-text-regular); font-size: 13px; - margin-left: 14px; + margin-left: 16px; user-select: none; } @@ -677,12 +754,37 @@ const onCommand = (cmd: string) => { } .collapse-btn { - border: 1px solid var(--ctms-border-color); - color: var(--ctms-text-regular); + border: 1px solid rgba(0, 0, 0, 0.08); + color: var(--ctms-text-secondary); + background: rgba(255, 255, 255, 0.6); + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: all 0.2s ease; +} + +.collapse-btn:hover { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(0, 0, 0, 0.14); + color: var(--ctms-text-main); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.collapse-btn:active { + transform: scale(0.95); + background: rgba(0, 0, 0, 0.04); } .collapse-icon { - font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; } @@ -695,37 +797,42 @@ const onCommand = (cmd: string) => { .dropdown-trigger.compact { display: flex; align-items: center; - padding: 4px 12px; - border-radius: 999px; - background: #ffffff; - border: 1px solid var(--ctms-border-color); + padding: 3px 14px 3px 4px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(0, 0, 0, 0.07); color: var(--ctms-text-regular); - transition: var(--ctms-transition); - height: 36px; + transition: all 0.2s ease; + height: 34px; + gap: 6px; } .dropdown-trigger.compact:hover { - background: var(--ctms-bg-muted); - border-color: var(--ctms-border-color-hover); + background: rgba(255, 255, 255, 0.9); + border-color: rgba(0, 0, 0, 0.14); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .user-avatar-comp { - color: var(--ctms-primary) !important; - background-color: transparent !important; - border-radius: 10px !important; + color: #ffffff !important; + background-color: var(--ctms-primary) !important; + border-radius: 7px !important; font-weight: 700; flex-shrink: 0; - font-size: 14px; + font-size: 13px; + width: 26px !important; + height: 26px !important; } .username { - font-size: 14px; - font-weight: 600; - margin: 0 10px 0 8px; - max-width: 150px; + font-size: 13px; + font-weight: 500; + margin: 0 2px; + max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--ctms-text-main); } .layout-main { @@ -743,22 +850,52 @@ const onCommand = (cmd: string) => { .ctms-route-shell { min-height: 100%; + transform-origin: top center; } /* 过渡动画 */ .fade-transform-enter-active, .fade-transform-leave-active { - transition: all 0.3s; + transition: + opacity 180ms ease, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + filter 180ms ease; } .fade-transform-enter-from { opacity: 0; - transform: translateX(-15px); + filter: blur(3px); + transform: translateY(8px) scale(0.995); } .fade-transform-leave-to { opacity: 0; - transform: translateX(15px); + filter: blur(2px); + transform: translateY(-4px) scale(0.998); +} + +@media (prefers-reduced-motion: reduce) { + .layout-aside, + .aside-menu, + .aside-logo, + .logo-text, + .layout-aside :deep(.el-menu-item), + .layout-aside :deep(.el-sub-menu__title), + .layout-aside :deep(.el-menu-item .el-icon), + .layout-aside :deep(.el-sub-menu__title .el-icon), + .layout-aside :deep(.el-sub-menu__icon-arrow), + .fade-transform-enter-active, + .fade-transform-leave-active { + transition-duration: 1ms !important; + } + + .layout-aside :deep(.el-menu-item:hover), + .layout-aside :deep(.el-sub-menu__title:hover), + .fade-transform-enter-from, + .fade-transform-leave-to { + filter: none; + transform: none; + } } .breadcrumb-dropdown-menu { min-width: 200px; diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index e24d89c3..48a19db2 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -39,7 +39,13 @@ describe("admin project route permissions", () => { expect(projectPermissionRoute).toContain("systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG"); expect(projectPermissionRoute).not.toContain("requiresProjectPm: true"); expect(source).toContain("to.meta.systemPermission"); - expect(source).toContain("hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin)"); + expect(source).toContain("hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin, to)"); + expect(source).toContain("const targetProjectId = getTargetProjectId(to);"); + expect(source).toContain('const matchedProject = items.find((study: any) => study.id === targetProjectId);'); + const targetProjectBranchStart = source.indexOf("if (targetProjectId) {"); + const targetProjectBranchEnd = source.indexOf("const hasPmProject", targetProjectBranchStart); + const targetProjectBranch = source.slice(targetProjectBranchStart, targetProjectBranchEnd); + expect(targetProjectBranch).not.toContain("ensureDefaultPmStudy"); }); it("keeps login landing independent from PM management backend access", () => { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index fb4ce0a3..0214ea89 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -4,7 +4,7 @@ import { useStudyStore } from "../store/study"; import { isSystemAdmin } from "../utils/roles"; import { isApiPermissionAllowed } from "../utils/apiPermissionValue"; import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions"; -import { fetchStudyDetail } from "../api/studies"; +import { fetchStudies, fetchStudyDetail } from "../api/studies"; import { fetchApiEndpointPermissions } from "../api/projectPermissions"; import Layout from "../components/Layout.vue"; import Login from "../views/Login.vue"; @@ -419,16 +419,32 @@ const SYSTEM_PERMISSION_ROLES: Record = { [SYSTEM_PERMISSION_MONITORING_METRICS]: ["ADMIN", "PM"], }; -const hasSystemPermissionAccess = async (permissionKey: string, isAdmin: boolean) => { +const getTargetProjectId = (to: any) => { + const queryProjectId = typeof to.query?.projectId === "string" ? to.query.projectId : ""; + const paramProjectId = typeof to.params?.projectId === "string" ? to.params.projectId : ""; + const paramId = typeof to.params?.id === "string" ? to.params.id : ""; + return queryProjectId || paramProjectId || paramId; +}; + +const hasSystemPermissionAccess = async (permissionKey: string, isAdmin: boolean, to: any) => { const roles = SYSTEM_PERMISSION_ROLES[permissionKey] || []; if (isAdmin) return roles.includes("ADMIN"); if (!roles.includes("PM")) return false; const studyStore = useStudyStore(); - const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; - if (!hasPmProject) { - await studyStore.ensureDefaultPmStudy().catch(() => {}); + const targetProjectId = getTargetProjectId(to); + if (targetProjectId) { + if (studyStore.currentStudy?.id === targetProjectId) { + return studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; + } + const { data } = await fetchStudies(); + const items = (data as any).items || []; + const matchedProject = items.find((study: any) => study.id === targetProjectId); + return matchedProject?.role_in_study === "PM"; } + + const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; + if (!hasPmProject) await studyStore.ensureDefaultPmStudy().catch(() => {}); return studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; }; @@ -555,7 +571,7 @@ router.beforeEach(async (to, _from, next) => { } } if (to.meta.systemPermission) { - const allowed = await hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin); + const allowed = await hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin, to); if (!allowed) { next({ path: isAdmin ? "/admin/users" : "/admin/projects" }); return; diff --git a/frontend/src/styles/unified-page.css b/frontend/src/styles/unified-page.css index 15787aa8..5e53f505 100644 --- a/frontend/src/styles/unified-page.css +++ b/frontend/src/styles/unified-page.css @@ -2,7 +2,7 @@ --unified-shell-bg: #ffffff; --unified-shell-border: #d8e2ef; --unified-shell-divider: #eaf0f8; - --unified-shell-radius: 16px; + --unified-shell-radius: 8px; --unified-shell-padding-x: 16px; --unified-shell-padding-y: 10px; --unified-title-color: #0f2345; diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index 88475dfe..fbdb98b8 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -47,8 +47,8 @@ describe("project management access", () => { 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("canConfigureProject(scope.row)"); + expect(source).toContain("canManageProjectBackend(scope.row)"); expect(source).toContain("canProject(scope.row, 'audit_export', 'read')"); }); @@ -74,13 +74,33 @@ describe("project management access", () => { expect(source).not.toContain('fixed="right"'); }); - it("opens project names in management detail when the user has setup config read permission", () => { + it("renders project names as plain text and moves setup configuration to a gear action", () => { const source = readProjects(); - expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'read')\""); - expect(source).toContain('@click="goProject(scope.row)" class="project-name"'); + expect(source).not.toContain("{{ scope.row.name }}'); + expect(source).toContain('v-if="canProject(scope.row, \'setup_config\', \'write\')" content="项目配置"'); + expect(source).toContain(':icon="Setting"'); + expect(source).toContain('@click="goProject(scope.row)"'); expect(source).toContain('@click="enterStudy(scope.row)"'); - expect(source).toContain('if (!canProject(row, "setup_config", "read"))'); + expect(source).toContain('if (!canProject(row, "setup_config", "write"))'); + }); + + it("shows project permission configuration only for admins and target project PMs", () => { + const source = readProjects(); + + expect(source).toContain('v-if="canConfigureProject(scope.row)" content="权限管理"'); + expect(source).toContain("const canConfigureProject = (row: Study) => canManageProjectBackend(row);"); + expect(source).not.toContain("canProject(scope.row, 'project_members', 'write')"); + }); + + it("shows center management only for admins and target project PMs", () => { + const source = readProjects(); + + expect(source).toContain('v-if="canManageProjectBackend(scope.row)" :content="TEXT.modules.adminProjects.sites"'); + expect(source).toContain("const canManageProjectBackend = (row: Study) => isAdmin.value || row.role_in_study === \"PM\";"); + expect(source).not.toContain("canProject(scope.row, 'sites', 'read')"); }); it("checks project management actions against the current row role instead of the first permission role", () => { diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index 05d69bc7..c1325451 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -74,8 +74,7 @@
- {{ scope.row.name }} - {{ scope.row.name }} + {{ scope.row.name }} {{ scope.row.code || '-' }}
@@ -123,10 +122,13 @@ - + + + + - + @@ -155,7 +157,7 @@ import { computed, onMounted, ref } from "vue"; 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 { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document, Plus, Setting } from "@element-plus/icons-vue"; import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies"; import { fetchMyApiEndpointPermissions } from "../../api/projectPermissions"; import type { ApiEndpointPermissionsResponse, Study } from "../../types/api"; @@ -232,15 +234,26 @@ const canProject = (row: Study, module: string, action: "read" | "write") => { return isApiPermissionAllowed(rolePerms[operationKey]); }; +const canManageProjectBackend = (row: Study) => isAdmin.value || row.role_in_study === "PM"; +const canConfigureProject = (row: Study) => canManageProjectBackend(row); + const goMembers = (row: Study) => { router.push(`/admin/permissions/project?projectId=${row.id}&sub=members`); }; const goSites = (row: Study) => { + if (!canManageProjectBackend(row)) { + ElMessage.warning("仅项目 PM 可管理该项目中心"); + return; + } router.push(`/admin/projects/${row.id}/sites`); }; const goPermissions = (row: Study) => { + if (!canConfigureProject(row)) { + ElMessage.warning("仅项目 PM 可配置该项目权限"); + return; + } router.push(`/admin/permissions/project?projectId=${row.id}`); }; @@ -342,8 +355,8 @@ const goDetail = (row: Study) => { }; const goProject = (row: Study) => { - if (!canProject(row, "setup_config", "read")) { - ElMessage.warning("当前角色无权访问该项目的立项配置"); + if (!canProject(row, "setup_config", "write")) { + ElMessage.warning("当前角色无权编辑该项目的立项配置"); return; } goDetail(row); @@ -364,7 +377,6 @@ onMounted(() => { grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 16px; - border-bottom: 1px solid var(--unified-shell-divider); } .stat-card { @@ -462,13 +474,6 @@ 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); diff --git a/frontend/src/views/admin/Sites.vue b/frontend/src/views/admin/Sites.vue index 4ef912f6..37e8b52d 100644 --- a/frontend/src/views/admin/Sites.vue +++ b/frontend/src/views/admin/Sites.vue @@ -9,7 +9,7 @@
- + - - - + + + - + - + - +