diff --git a/README.md b/README.md index 6e15970c..d2154062 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ > 其余历史示例账号已被停用,仅保留以上 5 个。若需新增,可用管理员在 `/api/v1/users` 创建,并在项目成员中赋予角色。 +## 角色权限概要(前端操作级提示,后端仍最终裁决) +- ADMIN:全权限 +- PM:里程碑/任务维护、受试者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护 +- CRA:任务/受试者/AE/Data Query 创建,受试者状态更新,Finance 创建/提交,其他只读 +- PV:AE 创建/关闭,Issue 创建/关闭,其余只读 +- IMP:IMP 交易/产品/批次维护,其余只读 +- 普通成员(无项目角色):仅浏览 + ## 访问方式 - 前端:`http://localhost`(Nginx 反代到 Vite 开发容器) - 后端 API:同域 `/api/v1/*`(已在前端代理) diff --git a/frontend/src/components/FaqCategoryPanel.vue b/frontend/src/components/FaqCategoryPanel.vue index 3dcd72c4..2f978b25 100644 --- a/frontend/src/components/FaqCategoryPanel.vue +++ b/frontend/src/components/FaqCategoryPanel.vue @@ -3,7 +3,9 @@
分类
- 新建 + + 新建 +
@@ -12,15 +14,16 @@ {{ c.name }} 项目 全局 - - 编辑 - + + + 编辑 + + @@ -33,6 +36,8 @@ import FaqCategoryForm from "./FaqCategoryForm.vue"; import type { FaqCategory } from "../api/faqs"; import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; +import PermissionAction from "./PermissionAction.vue"; +import { usePermission } from "../utils/permission"; const props = defineProps<{ categories: FaqCategory[]; @@ -46,8 +51,9 @@ const study = useStudyStore(); const showForm = ref(false); const editing = ref(null); +const { can } = usePermission(); const isAdmin = computed(() => auth.user?.role === "ADMIN"); -const canMaintain = computed(() => isAdmin.value || auth.user?.role === "PM"); +const canMaintain = computed(() => can("faq.edit")); const sortedCategories = computed(() => [...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) @@ -56,8 +62,9 @@ const sortedCategories = computed(() => const activeCategory = computed(() => props.modelValue || ""); const canEdit = (c: FaqCategory) => { + if (!canMaintain.value) return false; if (isAdmin.value) return true; - return auth.user?.role === "PM" && c.study_id === study.currentStudy?.id; + return c.study_id === study.currentStudy?.id; }; const onSelect = (id: string) => { diff --git a/frontend/src/components/FaqList.vue b/frontend/src/components/FaqList.vue index 882dc74c..a429737c 100644 --- a/frontend/src/components/FaqList.vue +++ b/frontend/src/components/FaqList.vue @@ -16,16 +16,19 @@ @@ -37,6 +40,7 @@ import { computed } from "vue"; import { useRouter } from "vue-router"; import { updateFaqItem } from "../api/faqs"; import type { FaqItem, FaqCategory } from "../api/faqs"; +import PermissionAction from "./PermissionAction.vue"; const props = defineProps<{ items: FaqItem[]; diff --git a/frontend/src/components/FinanceStatusActions.vue b/frontend/src/components/FinanceStatusActions.vue index b4d43329..9f65a9d2 100644 --- a/frontend/src/components/FinanceStatusActions.vue +++ b/frontend/src/components/FinanceStatusActions.vue @@ -1,63 +1,65 @@ + + diff --git a/frontend/src/store/study.ts b/frontend/src/store/study.ts index 79f14c20..55638acd 100644 --- a/frontend/src/store/study.ts +++ b/frontend/src/store/study.ts @@ -3,13 +3,20 @@ import { ref } from "vue"; import type { Study } from "../types/api"; const STUDY_KEY = "ctms_current_study"; +const STUDY_ROLE_KEY = "ctms_current_study_role"; export const useStudyStore = defineStore("study", () => { const currentStudy = ref(null); + const currentStudyRole = ref(null); const setCurrentStudy = (study: Study) => { currentStudy.value = study; + const role = (study as any).role_in_study || (study as any).role || null; + currentStudyRole.value = role; localStorage.setItem(STUDY_KEY, JSON.stringify(study)); + if (role) { + localStorage.setItem(STUDY_ROLE_KEY, role); + } }; const loadCurrentStudy = () => { @@ -21,16 +28,31 @@ export const useStudyStore = defineStore("study", () => { currentStudy.value = null; } } + const savedRole = localStorage.getItem(STUDY_ROLE_KEY); + currentStudyRole.value = savedRole || null; }; const clearCurrentStudy = () => { currentStudy.value = null; + currentStudyRole.value = null; localStorage.removeItem(STUDY_KEY); + localStorage.removeItem(STUDY_ROLE_KEY); + }; + + const setCurrentStudyRole = (role: string | null) => { + currentStudyRole.value = role; + if (role) { + localStorage.setItem(STUDY_ROLE_KEY, role); + } else { + localStorage.removeItem(STUDY_ROLE_KEY); + } }; return { currentStudy, + currentStudyRole, setCurrentStudy, + setCurrentStudyRole, loadCurrentStudy, clearCurrentStudy, }; diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts index b26b303e..de86c45e 100644 --- a/frontend/src/utils/permission.ts +++ b/frontend/src/utils/permission.ts @@ -1,6 +1,62 @@ -// 预留权限工具,可在后续基于 role 与项目内角色实现细粒度控制 -export const hasPermission = (requiredRoles: string[], userRole?: string): boolean => { - if (!requiredRoles.length) return true; - if (!userRole) return false; - return requiredRoles.includes(userRole); +import { computed } from "vue"; +import { useAuthStore } from "../store/auth"; +import { useStudyStore } from "../store/study"; + +const PERMISSIONS: Record = { + "task.create": ["ADMIN", "PM"], + "task.edit": ["ADMIN", "PM"], + "milestone.create": ["ADMIN", "PM"], + "subject.create": ["ADMIN", "PM", "CRA"], + "subject.enroll": ["ADMIN", "PM", "CRA"], + "subject.complete": ["ADMIN", "PM", "CRA"], + "subject.drop": ["ADMIN", "PM", "CRA"], + "ae.create": ["ADMIN", "PM", "CRA", "PV"], + "ae.close": ["ADMIN", "PM", "PV", "CRA"], + "issue.create": ["ADMIN", "PM", "PV"], + "issue.close": ["ADMIN", "PM", "PV"], + "finance.create": ["ADMIN", "PM", "CRA"], + "finance.approve": ["ADMIN", "PM"], + "finance.pay": ["ADMIN", "PM"], + "imp.transaction": ["ADMIN", "PM", "IMP"], + "faq.edit": ["ADMIN", "PM"], +}; + +const REASONS: Record = { + "task.create": "仅项目负责人可以创建任务", + "task.edit": "仅项目负责人可以编辑任务", + "milestone.create": "仅项目负责人可以维护里程碑", + "subject.enroll": "仅 PM/CRA 可更新受试者状态", + "subject.complete": "仅 PM/CRA 可更新受试者状态", + "subject.drop": "仅 PM/CRA 可更新受试者状态", + "ae.close": "仅 PM/PV/ADMIN 可关闭 AE", + "finance.approve": "审批需要 PM/ADMIN 权限", + "finance.pay": "支付确认需要 PM/ADMIN 权限", + "imp.transaction": "仅 IMP/PM/ADMIN 可操作台账", + "faq.edit": "仅 PM/ADMIN 可维护 FAQ", +}; + +export const usePermission = () => { + const auth = useAuthStore(); + const study = useStudyStore(); + + const userRole = computed(() => auth.user?.role || null); + const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || null); + + const can = (action: string): boolean => { + if (userRole.value === "ADMIN") return true; + const allowList = PERMISSIONS[action]; + if (!allowList) return false; + if (!projectRole.value) return false; + return allowList.includes(projectRole.value); + }; + + const reason = (action: string): string => { + if (userRole.value === "ADMIN") return ""; + return REASONS[action] || "当前角色无权执行该操作"; + }; + + return { + can, + reason, + }; }; diff --git a/frontend/src/views/AeDetail.vue b/frontend/src/views/AeDetail.vue index b3abf00a..25848eb8 100644 --- a/frontend/src/views/AeDetail.vue +++ b/frontend/src/views/AeDetail.vue @@ -6,9 +6,11 @@

AE 详情

{{ ae.term }}

- - {{ ae.status === "CLOSED" ? "重新打开" : "关闭" }} - + + + {{ ae.status === "CLOSED" ? "重新打开" : "关闭" }} + + {{ ae.subject_id }} @@ -43,6 +45,8 @@ import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; import CommentList from "../components/CommentList.vue"; import AttachmentList from "../components/AttachmentList.vue"; +import PermissionAction from "../components/PermissionAction.vue"; +import { usePermission } from "../utils/permission"; const route = useRoute(); const study = useStudyStore(); @@ -51,10 +55,8 @@ const auth = useAuthStore(); const ae = ref(null); const studyId = computed(() => study.currentStudy?.id || ""); -const canClose = computed(() => { - // CRA 也显示按钮,后端会校验权限 - return true; -}); +const { can } = usePermission(); +const canClose = computed(() => can("ae.close")); const enrichOverdue = (item: any) => { if (!item) return item; diff --git a/frontend/src/views/Faq.vue b/frontend/src/views/Faq.vue index 73430ad8..65d02caf 100644 --- a/frontend/src/views/Faq.vue +++ b/frontend/src/views/Faq.vue @@ -25,7 +25,9 @@
- 新建 FAQ + + 新建 FAQ +
@@ -61,6 +63,8 @@ import FaqList from "../components/FaqList.vue"; import FaqItemForm from "../components/FaqItemForm.vue"; import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; +import PermissionAction from "../components/PermissionAction.vue"; +import { usePermission } from "../utils/permission"; const auth = useAuthStore(); const study = useStudyStore(); @@ -78,10 +82,8 @@ const activeCategory = ref(""); const showForm = ref(false); const editing = ref(null); -const canEdit = computed(() => { - const role = auth.user?.role; - return role === "ADMIN" || role === "PM"; -}); +const { can } = usePermission(); +const canEdit = computed(() => can("faq.edit")); const loadCategories = async () => { try { diff --git a/frontend/src/views/Finance.vue b/frontend/src/views/Finance.vue index 9dcd6429..f169ce1b 100644 --- a/frontend/src/views/Finance.vue +++ b/frontend/src/views/Finance.vue @@ -25,7 +25,9 @@ @change="loadList" />
- 新建费用 + + 新建费用 +
@@ -71,6 +73,8 @@ import FinanceForm from "../components/FinanceForm.vue"; import { fetchFinanceItems, fetchFinanceSummary } from "../api/finance"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; +import PermissionAction from "../components/PermissionAction.vue"; +import { usePermission } from "../utils/permission"; const study = useStudyStore(); const auth = useAuthStore(); @@ -95,10 +99,8 @@ const categories = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"]; const showForm = ref(false); -const canCreate = computed(() => { - const role = auth.user?.role; - return role === "ADMIN" || role === "PM" || role === "CRA"; -}); +const { can } = usePermission(); +const canCreate = computed(() => can("finance.create")); const formatAmount = (num?: number | string) => { const n = Number(num); diff --git a/frontend/src/views/FinanceDetail.vue b/frontend/src/views/FinanceDetail.vue index 41c636e2..1f0313d0 100644 --- a/frontend/src/views/FinanceDetail.vue +++ b/frontend/src/views/FinanceDetail.vue @@ -7,7 +7,9 @@ {{ item?.status }}
- 编辑 + + 编辑 +
@@ -36,9 +38,11 @@ import { useRoute } from "vue-router"; import { ElMessage } from "element-plus"; import FinanceStatusActions from "../components/FinanceStatusActions.vue"; import FinanceForm from "../components/FinanceForm.vue"; +import PermissionAction from "../components/PermissionAction.vue"; import { fetchFinanceItem } from "../api/finance"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; +import { usePermission } from "../utils/permission"; const study = useStudyStore(); const auth = useAuthStore(); @@ -47,10 +51,8 @@ const route = useRoute(); const item = ref(null); const loading = ref(false); const showEdit = ref(false); -const canEditDraft = computed(() => { - const role = auth.user?.role; - return item.value?.status === "DRAFT" && (role === "ADMIN" || role === "PM" || role === "CRA"); -}); +const { can } = usePermission(); +const canEditDraft = computed(() => item.value?.status === "DRAFT" && can("finance.create")); const loadItem = async () => { if (!study.currentStudy) return; diff --git a/frontend/src/views/ImpTransactions.vue b/frontend/src/views/ImpTransactions.vue index 53760bfd..ea55bdfb 100644 --- a/frontend/src/views/ImpTransactions.vue +++ b/frontend/src/views/ImpTransactions.vue @@ -22,7 +22,9 @@ @change="load" />
- 新增交易 + + 新增交易 +
@@ -55,6 +57,8 @@ import { fetchImpTransactions } from "../api/impTransactions"; import { fetchImpBatches } from "../api/impBatches"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; +import PermissionAction from "../components/PermissionAction.vue"; +import { usePermission } from "../utils/permission"; import ImpTransactionForm from "../components/ImpTransactionForm.vue"; const study = useStudyStore(); @@ -70,10 +74,8 @@ const loading = ref(false); const showForm = ref(false); const batches = ref([]); -const canEdit = computed(() => { - const role = auth.user?.role; - return role === "ADMIN" || role === "PM" || role === "IMP"; -}); +const { can } = usePermission(); +const canEdit = computed(() => can("imp.transaction")); const loadBatches = async () => { if (!study.currentStudy) return; diff --git a/frontend/src/views/IssueDetail.vue b/frontend/src/views/IssueDetail.vue index ad8303eb..dd68df83 100644 --- a/frontend/src/views/IssueDetail.vue +++ b/frontend/src/views/IssueDetail.vue @@ -6,7 +6,9 @@

Issue 详情

{{ issue.title }}

- 关闭 + + 关闭 + {{ issue.category }} @@ -38,20 +40,18 @@ import { ElMessage, ElMessageBox } from "element-plus"; import { fetchIssues, updateIssue } from "../api/issues"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; +import PermissionAction from "../components/PermissionAction.vue"; +import { usePermission } from "../utils/permission"; import CommentList from "../components/CommentList.vue"; import AttachmentList from "../components/AttachmentList.vue"; const route = useRoute(); const study = useStudyStore(); const auth = useAuthStore(); - const issue = ref(null); const studyId = computed(() => study.currentStudy?.id || ""); - -const canEdit = computed(() => { - const role = auth.user?.role; - return role === "ADMIN" || role === "PM" || role === "PV"; -}); +const { can } = usePermission(); +const canEdit = computed(() => can("issue.close")); const load = async () => { if (!study.currentStudy) return; diff --git a/frontend/src/views/Milestones.vue b/frontend/src/views/Milestones.vue index f68eeff9..ce64a626 100644 --- a/frontend/src/views/Milestones.vue +++ b/frontend/src/views/Milestones.vue @@ -3,7 +3,9 @@

里程碑

- 新增里程碑 + + 新增里程碑 +
@@ -15,9 +17,11 @@ - + @@ -28,23 +32,22 @@