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 @@
查看
- 编辑
-
- {{ scope.row.is_active ? "停用" : "启用" }}
-
+
+ 编辑
+
+
+
+ {{ scope.row.is_active ? "停用" : "启用" }}
+
+
@@ -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 @@