From e78bb04c4b6a9acb8ecd912d8e28d8b8f8be4b0d Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 4 Jun 2026 16:31:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A1=B9=E7=9B=AE=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E4=B8=8E=E8=A7=92=E8=89=B2=E6=8E=92=E5=BA=8F=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录成功后按用户恢复上次项目,管理员优先选择启用项目\n- 项目详情路由增加 setup_config:read 权限校验,未授权时回到可访问页面\n- 项目管理操作按当前项目行角色读取权限,避免误用首个角色权限\n- 角色模板排序中固定 PM 优先,保持角色列表、生效管理和权限编辑器顺序一致\n- 补充登录、路由、项目管理和角色排序相关测试 --- .../composables/useRoleTemplateMeta.test.ts | 16 ++++++------- .../src/composables/useRoleTemplateMeta.ts | 2 ++ frontend/src/router.test.ts | 3 +++ frontend/src/router/index.ts | 23 ++++++++++++++++++- frontend/src/views/Login.test.ts | 13 +++++++++++ frontend/src/views/Login.vue | 9 ++------ frontend/src/views/admin/Projects.test.ts | 8 +++++++ frontend/src/views/admin/Projects.vue | 3 ++- 8 files changed, 60 insertions(+), 17 deletions(-) diff --git a/frontend/src/composables/useRoleTemplateMeta.test.ts b/frontend/src/composables/useRoleTemplateMeta.test.ts index c0373438..b2587780 100644 --- a/frontend/src/composables/useRoleTemplateMeta.test.ts +++ b/frontend/src/composables/useRoleTemplateMeta.test.ts @@ -6,11 +6,11 @@ import { useRoleTemplateMeta } from "./useRoleTemplateMeta"; vi.mock("@/api/projectPermissions", () => ({ fetchPermissionTemplates: vi.fn().mockResolvedValue({ data: [ - { category: "PM", name: "PM" }, - { category: "CRA", name: "CRA" }, - { category: "CTA", name: "CTA" }, - { category: "QA", name: "QA" }, { category: "PV", name: "PV" }, + { category: "QA", name: "QA" }, + { category: "CTA", name: "CTA" }, + { category: "CRA", name: "CRA" }, + { category: "PM", name: "PM" }, ], }), })); @@ -31,16 +31,16 @@ describe("useRoleTemplateMeta fallback copy", () => { expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。"); }); - it("sorts roles by the role template list order", async () => { + it("pins PM first before applying the role template list order", async () => { const { compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta(); await loadRoleTemplates(); expect(["CRA", "PV", "QA", "PM", "CTA"].sort(compareRolesByTemplateOrder)).toEqual([ "PM", - "CRA", - "CTA", - "QA", "PV", + "QA", + "CTA", + "CRA", ]); }); }); diff --git a/frontend/src/composables/useRoleTemplateMeta.ts b/frontend/src/composables/useRoleTemplateMeta.ts index 2c147bc6..60312cc7 100644 --- a/frontend/src/composables/useRoleTemplateMeta.ts +++ b/frontend/src/composables/useRoleTemplateMeta.ts @@ -1,5 +1,6 @@ import { computed, ref } from "vue"; import { fetchPermissionTemplates, type PermissionTemplate } from "@/api/projectPermissions"; +import { compareProjectPermissionRoles } from "@/utils/projectPermissionRoles"; const FALLBACK_ROLE_META: Record = { ADMIN: { label: "管理员", icon: "🛡️", desc: "系统管理员" }, @@ -58,6 +59,7 @@ export const useRoleTemplateMeta = () => { }); const compareRolesByTemplateOrder = (a: string, b: string) => { + if (a === "PM" || b === "PM") return compareProjectPermissionRoles(a, b); const order = roleOrder.value; const aOrder = order[a] ?? Number.MAX_SAFE_INTEGER; const bOrder = order[b] ?? Number.MAX_SAFE_INTEGER; diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index 3d176654..e24d89c3 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -10,6 +10,9 @@ describe("admin project route permissions", () => { expect(source).toContain('name: "AdminProjectDetail"'); expect(source).toContain("requiresProjectMember: true"); + expect(source).toContain('projectPermission: "setup_config:read"'); + expect(source).toContain("to.meta.projectPermission"); + expect(source).toContain("ensureProjectPermissionAccess(to.meta.projectPermission as string, isAdmin, studyStore)"); expect(source).not.toContain("meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true }"); expect(source).toContain("ensureRouteProjectMember(to, studyStore)"); }); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c41073b7..fb4ce0a3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -350,7 +350,7 @@ const routes: RouteRecordRaw[] = [ path: "projects/:projectId", name: "AdminProjectDetail", component: ProjectDetail, - meta: { title: TEXT.modules.adminProjects.detailTitle, requiresProjectMember: true }, + meta: { title: TEXT.modules.adminProjects.detailTitle, requiresProjectMember: true, projectPermission: "setup_config:read" }, }, { path: "projects/:projectId/members", @@ -466,6 +466,20 @@ const ensureRouteProjectMember = async (to: any, studyStore: ReturnType, +) => { + if (isAdmin) return true; + if (!operationKey || !studyStore.currentStudy) return false; + if (!studyStore.currentPermissions) { + await studyStore.loadCurrentStudyPermissions(); + } + const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; + return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]); +}; + router.beforeEach(async (to, _from, next) => { const auth = useAuthStore(); const studyStore = useStudyStore(); @@ -533,6 +547,13 @@ router.beforeEach(async (to, _from, next) => { return; } } + if (to.meta.projectPermission) { + const allowed = await ensureProjectPermissionAccess(to.meta.projectPermission as string, isAdmin, studyStore).catch(() => false); + if (!allowed) { + next({ path: isAdmin ? "/admin/users" : "/admin/projects" }); + return; + } + } if (to.meta.systemPermission) { const allowed = await hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin); if (!allowed) { diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts index 671b7852..72a54f0a 100644 --- a/frontend/src/views/Login.test.ts +++ b/frontend/src/views/Login.test.ts @@ -61,4 +61,17 @@ describe("Login protocol agreement", () => { expect(source).toContain(":autocomplete=\"form.rememberPassword ? 'current-password' : 'new-password'\""); expect(source).not.toContain('autocomplete="current-password"'); }); + + it("restores the current user's last project before routing after login", () => { + const source = readLoginView(); + const loginCallIndex = source.indexOf("auth.login(form.email, form.password)"); + const userKeyIndex = source.indexOf("const userKey = auth.user?.email || form.email"); + const restoreIndex = source.indexOf("studyStore.restoreStudyForUser(userKey"); + const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")'); + + expect(userKeyIndex).toBeGreaterThan(loginCallIndex); + expect(restoreIndex).toBeGreaterThan(loginCallIndex); + expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex); + expect(source).toContain("preferActive: !!auth.user?.is_admin"); + }); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 7707a90f..de73eb34 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -239,13 +239,8 @@ const onSubmit = async () => { await auth.login(form.email, form.password); await tryStoreBrowserCredential(form.email, form.password); const studyStore = useStudyStore(); - if (!studyStore.currentStudy) { - if (auth.user?.is_admin) { - await studyStore.ensureDefaultActiveStudy(); - } else { - await studyStore.ensureDefaultStudy(); - } - } + const userKey = auth.user?.email || form.email; + await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin }); if (studyStore.currentStudy) { router.push("/project/overview"); } else { diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index ffbddeb1..88475dfe 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -83,6 +83,14 @@ describe("project management access", () => { expect(source).toContain('if (!canProject(row, "setup_config", "read"))'); }); + it("checks project management actions against the current row role instead of the first permission role", () => { + const source = readProjects(); + + expect(source).toContain("const role = row.role_in_study || \"\";"); + expect(source).toContain("const rolePerms = perms[role];"); + expect(source).not.toContain("Object.values(perms)[0]"); + }); + it("keeps audit export write mappings on the export operation", () => { const projects = readProjects(); const router = readRouter(); diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index e805fbe8..05d69bc7 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -226,7 +226,8 @@ const canProject = (row: Study, module: string, action: "read" | "write") => { if (!operationKey) return false; const perms = permissionsByProject.value[row.id]; if (!perms) return false; - const rolePerms = Object.values(perms)[0]; + const role = row.role_in_study || ""; + const rolePerms = perms[role]; if (!rolePerms) return false; return isApiPermissionAllowed(rolePerms[operationKey]); };