diff --git a/backend/app/api/v1/fees_contracts.py b/backend/app/api/v1/fees_contracts.py index 1324bed6..77ce8838 100644 --- a/backend/app/api/v1/fees_contracts.py +++ b/backend/app/api/v1/fees_contracts.py @@ -314,7 +314,7 @@ async def create_contract_payment( contract = await contract_fee_crud.get_contract_fee(db, contract_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") - await _ensure_project_access(db, contract.project_id, current_user, "POST:/fees/contracts/{id}/payments") + await _ensure_project_access(db, contract.project_id, current_user, "fees_payments:create") _validate_payment_rules(payment_in) payment = await payment_crud.create_payment(db, contract_id, payment_in) await audit_crud.log_action( @@ -347,7 +347,7 @@ async def update_contract_payment( contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") - await _ensure_project_access(db, contract.project_id, current_user, "PATCH:/fees/payments/{id}") + await _ensure_project_access(db, contract.project_id, current_user, "fees_payments:update") merged_payment = ContractFeePaymentCreate( amount=payment_in.amount if payment_in.amount is not None else payment.amount, paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date, @@ -387,7 +387,7 @@ async def delete_contract_payment( contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") - await _ensure_project_access(db, contract.project_id, current_user, "DELETE:/fees/payments/{id}") + await _ensure_project_access(db, contract.project_id, current_user, "fees_payments:delete") await payment_crud.delete_payment(db, payment) await payment_crud.resequence_payments(db, contract.id) await audit_crud.log_action( diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 0c165a09..e825f795 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -35,6 +35,14 @@ from app.schemas.user import UserDisplay UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents" +DOCUMENT_ACTION_PERMISSIONS = { + "view": "documents:read", + "ack": "documents:read", + "create_document": "documents:create", + "create_version": "documents:update", + "delete_document": "documents:delete", +} + def _role_value(user) -> str: return user.role.value if hasattr(user.role, "value") else str(user.role) @@ -75,7 +83,7 @@ async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_us membership = await member_crud.get_member(db, trial_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - endpoint_key = "documents:read" if action in {"view", "ack"} else "documents:update" + endpoint_key = DOCUMENT_ACTION_PERMISSIONS.get(action, "documents:update") allowed = await role_has_api_permission(db, trial_id, membership.role_in_study, endpoint_key, check_prerequisites=False) if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index b1eb51b2..0bdd6e71 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -3,7 +3,7 @@ import pytest import uuid from pathlib import Path -import re +import ast from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +14,7 @@ from app.core.permission_cache import PermissionCache, set_permission_cache from app.core.permission_monitor import PermissionMonitor, set_permission_monitor from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.study import Study +from app.models.user import UserRole from app.schemas.member import StudyMemberCreate @@ -43,20 +44,46 @@ def test_all_backend_permission_guards_are_configurable(): """确保后端实际鉴权使用的 operation key 都能在权限管理中配置。""" api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" used_keys: set[str] = set() - pattern = re.compile(r"require_api_permission\(\s*[\"']([^\"']+)[\"']") for path in api_dir.rglob("*.py"): - used_keys.update(pattern.findall(path.read_text())) + source = path.read_text() + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Name) and node.func.id == "require_api_permission": + if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str): + used_keys.add(node.args[0].value) + if isinstance(node.func, ast.Name) and node.func.id == "_ensure_project_access": + if len(node.args) >= 4 and isinstance(node.args[3], ast.Constant) and isinstance(node.args[3].value, str): + used_keys.add(node.args[3].value) assert used_keys assert used_keys <= set(API_ENDPOINT_PERMISSIONS) +def test_document_service_uses_specific_document_permission_keys(): + """文档模块应使用 create/update/delete 细粒度权限,而不是全部退化为 update。""" + service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "document_service.py" + source = service_path.read_text() + + assert '"create_document": "documents:create"' in source + assert '"create_version": "documents:update"' in source + assert '"delete_document": "documents:delete"' in source + assert 'else "documents:update"' not in source + + +def test_user_role_no_longer_contains_qa(): + assert "QA" not in {role.value for role in UserRole} + + @pytest.mark.asyncio async def test_default_matrix_covers_every_role_and_permission(db_session: AsyncSession): - """逐一验证 6 个预设角色在每个权限上的默认矩阵。""" + """逐一验证预设项目权限角色在每个权限上的默认矩阵。""" study_id = uuid.uuid4() matrix = await get_api_endpoint_permissions(db_session, study_id) + assert "QA" not in PROJECT_PERMISSION_ROLES + assert all("QA" not in config["default_roles"] for config in API_ENDPOINT_PERMISSIONS.values()) assert set(matrix) == set(PROJECT_PERMISSION_ROLES) for role in PROJECT_PERMISSION_ROLES: assert set(matrix[role]) == set(API_ENDPOINT_PERMISSIONS) diff --git a/frontend/src/utils/permission.test.ts b/frontend/src/utils/permission.test.ts index 19944c2c..f9d42232 100644 --- a/frontend/src/utils/permission.test.ts +++ b/frontend/src/utils/permission.test.ts @@ -13,10 +13,16 @@ describe("permission project role model", () => { expect(source).toContain("study.currentStudyRole"); expect(source).toContain("study.currentPermissions"); expect(source).toContain('"faq.edit": "faq:update"'); + expect(source).toContain('"fees.contract.create": "fees_contracts:create"'); + expect(source).toContain('"fees.contract.update": "fees_contracts:update"'); + expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"'); + expect(source).toContain('"documents.create": "documents:create"'); + expect(source).toContain('"documents.delete": "documents:delete"'); expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]"); expect(source).not.toContain("projectPermissions.value?.roles"); expect(source).not.toContain('"faq.edit": "etmf'); expect(source).not.toContain('"ADMIN", "PM"'); + expect(source).not.toContain('"fees.contract.write": "fees_contracts:create"'); }); }); diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts index e1735786..2b94457c 100644 --- a/frontend/src/utils/permission.ts +++ b/frontend/src/utils/permission.ts @@ -18,7 +18,11 @@ const PERMISSIONS: Record = { "project.members.manage": ["ADMIN"], "site.manage": ["ADMIN"], "site.cra.bind": ["ADMIN"], - "fees.contract.write": ["ADMIN"], + "fees.contract.create": ["ADMIN"], + "fees.contract.update": ["ADMIN"], + "fees.contract.delete": ["ADMIN"], + "documents.create": ["ADMIN"], + "documents.delete": ["ADMIN"], }; const REASONS: Record = { @@ -32,7 +36,11 @@ const REASONS: Record = { "project.members.manage": TEXT.modules.permissions.projectMembersManage, "site.manage": TEXT.modules.permissions.siteManage, "site.cra.bind": TEXT.modules.permissions.siteCraBind, - "fees.contract.write": TEXT.modules.permissions.feeContractsWrite, + "fees.contract.create": TEXT.modules.permissions.feeContractsWrite, + "fees.contract.update": TEXT.modules.permissions.feeContractsWrite, + "fees.contract.delete": TEXT.modules.permissions.feeContractsWrite, + "documents.create": TEXT.modules.permissions.default, + "documents.delete": TEXT.modules.permissions.default, }; export const usePermission = () => { @@ -63,7 +71,11 @@ export const usePermission = () => { "project.members.manage": "project_members:update", "site.manage": "sites:update", "site.cra.bind": "sites:update", - "fees.contract.write": "fees_contracts:create", + "fees.contract.create": "fees_contracts:create", + "fees.contract.update": "fees_contracts:update", + "fees.contract.delete": "fees_contracts:delete", + "documents.create": "documents:create", + "documents.delete": "documents:delete", "fees.attachment.delete": "fees_attachments:delete", "file.attachment.delete": "attachments:delete", "audit.export.read": "audit_logs:read", diff --git a/frontend/src/utils/projectRoutePermissions.test.ts b/frontend/src/utils/projectRoutePermissions.test.ts index 013bf140..1f90172e 100644 --- a/frontend/src/utils/projectRoutePermissions.test.ts +++ b/frontend/src/utils/projectRoutePermissions.test.ts @@ -6,11 +6,20 @@ describe("project route permissions", () => { expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" }); expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" }); expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" }); + expect(getProjectRoutePermission("/finance/contracts/new")).toEqual({ operationKey: "finance_contracts:create" }); + expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toEqual({ operationKey: "finance_contracts:update" }); expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" }); - expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:read" }); - expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:read" }); + expect(getProjectRoutePermission("/drug/shipments/new")).toEqual({ operationKey: "drug_shipments:create" }); + expect(getProjectRoutePermission("/drug/shipments/abc/edit")).toEqual({ operationKey: "drug_shipments:update" }); + expect(getProjectRoutePermission("/subjects/new")).toEqual({ operationKey: "subjects:create" }); + expect(getProjectRoutePermission("/subjects/abc/edit")).toEqual({ operationKey: "subjects:update" }); + expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:create" }); + expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "ethics:update" }); + expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:create" }); + expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toEqual({ operationKey: "startup_auth:update" }); expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" }); - expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:read" }); + expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:create" }); + expect(getProjectRoutePermission("/knowledge/notes/abc/edit")).toEqual({ operationKey: "knowledge_notes:update" }); }); it("rejects project routes when the role lacks operation permission", () => { diff --git a/frontend/src/utils/projectRoutePermissions.ts b/frontend/src/utils/projectRoutePermissions.ts index ca26a0a7..5289ac1f 100644 --- a/frontend/src/utils/projectRoutePermissions.ts +++ b/frontend/src/utils/projectRoutePermissions.ts @@ -6,6 +6,13 @@ export type ProjectRoutePermission = { }; const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [ + { prefixes: ["/fees/contracts/new"], permission: { operationKey: "fees_contracts:create" } }, + { prefixes: ["/finance/contracts/new"], permission: { operationKey: "finance_contracts:create" } }, + { prefixes: ["/drug/shipments/new"], permission: { operationKey: "drug_shipments:create" } }, + { prefixes: ["/startup/feasibility/new", "/startup/ethics/new"], permission: { operationKey: "ethics:create" } }, + { prefixes: ["/startup/kickoff/new", "/startup/training/new"], permission: { operationKey: "startup_auth:create" } }, + { prefixes: ["/subjects/new"], permission: { operationKey: "subjects:create" } }, + { prefixes: ["/knowledge/notes/new"], permission: { operationKey: "knowledge_notes:create" } }, { prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } }, { prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } }, { prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } }, @@ -23,6 +30,18 @@ const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePerm { prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "knowledge_notes:read" } }, ]; +const editRoutePermissions: Array<{ pattern: RegExp; permission: ProjectRoutePermission }> = [ + { pattern: /^\/fees\/contracts\/[^/]+\/edit$/, permission: { operationKey: "fees_contracts:update" } }, + { pattern: /^\/finance\/contracts\/[^/]+\/edit$/, permission: { operationKey: "finance_contracts:update" } }, + { pattern: /^\/drug\/shipments\/[^/]+\/edit$/, permission: { operationKey: "drug_shipments:update" } }, + { pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "ethics:update" } }, + { pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "ethics:update" } }, + { pattern: /^\/startup\/kickoff\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } }, + { pattern: /^\/startup\/training\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } }, + { pattern: /^\/subjects\/[^/]+\/edit$/, permission: { operationKey: "subjects:update" } }, + { pattern: /^\/knowledge\/notes\/[^/]+\/edit$/, permission: { operationKey: "knowledge_notes:update" } }, +]; + export const projectRouteLandingPaths = [ "/project/overview", "/project/milestones", @@ -44,6 +63,11 @@ export const projectRouteLandingPaths = [ export const getProjectRoutePermission = (path: string): ProjectRoutePermission | null => { const normalized = path || ""; + for (const item of editRoutePermissions) { + if (item.pattern.test(normalized)) { + return item.permission; + } + } for (const item of routePermissions) { if (item.prefixes.some((prefix) => normalized === prefix || normalized.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`))) { return item.permission; diff --git a/frontend/src/views/documents/DocumentList.test.ts b/frontend/src/views/documents/DocumentList.test.ts new file mode 100644 index 00000000..f1d0f171 --- /dev/null +++ b/frontend/src/views/documents/DocumentList.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./DocumentList.vue"), "utf8"); + +describe("DocumentList permissions", () => { + it("guards create and delete actions with document operation permissions", () => { + const source = readSource(); + + expect(source).toContain('can("documents.create")'); + expect(source).toContain('can("documents.delete")'); + expect(source).toContain(":disabled=\"!canCreate\""); + expect(source).toContain("v-if=\"canDelete\""); + }); +}); diff --git a/frontend/src/views/documents/DocumentList.vue b/frontend/src/views/documents/DocumentList.vue index 4862af14..d468bc92 100644 --- a/frontend/src/views/documents/DocumentList.vue +++ b/frontend/src/views/documents/DocumentList.vue @@ -22,7 +22,7 @@ {{ TEXT.common.actions.reset }}
- + {{ TEXT.modules.fileVersionManagement.newDocument }} @@ -75,7 +75,7 @@ @@ -128,6 +128,7 @@ import { fetchDocuments, createDocument, deleteDocument } from "../../api/docume import { fetchSites } from "../../api/sites"; import { useStudyStore } from "../../store/study"; import { useAuthStore } from "../../store/auth"; +import { usePermission } from "../../utils/permission"; import type { DocumentSummary } from "../../types/documents"; import type { Site } from "../../types/api"; import { displayEnum, displayText } from "../../utils/display"; @@ -137,6 +138,7 @@ const route = useRoute(); const router = useRouter(); const study = useStudyStore(); const auth = useAuthStore(); +const { can } = usePermission(); const loading = ref(false); const items = ref([]); const sites = ref([]); @@ -173,6 +175,8 @@ const siteActiveMap = computed(() => { const isAdmin = computed(() => { return !!auth.user?.is_admin; }); +const canCreate = computed(() => can("documents.create")); +const canDelete = computed(() => can("documents.delete")); const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false; const documentRowClass = ({ row }: { row: DocumentSummary }) => `${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim(); @@ -259,6 +263,10 @@ const resetFilters = () => { }; const openCreate = () => { + if (!canCreate.value) { + ElMessage.warning("权限不足"); + return; + } createForm.doc_no = ""; createForm.title = ""; createForm.scope_type = "GLOBAL"; @@ -268,6 +276,10 @@ const openCreate = () => { }; const submitCreate = async () => { + if (!canCreate.value) { + ElMessage.warning("权限不足"); + return; + } if (!createFormRef.value) return; await createFormRef.value.validate(async (valid) => { if (!valid || !trialId.value) return; @@ -305,6 +317,10 @@ const handleRowClick = (row: DocumentSummary) => { }; const confirmDelete = async (row: DocumentSummary) => { + if (!canDelete.value) { + ElMessage.warning("权限不足"); + return; + } if (!row?.id) return; const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); if (!ok) return; diff --git a/frontend/src/views/drug/ShipmentForm.vue b/frontend/src/views/drug/ShipmentForm.vue index eaee7b6c..f3fc4795 100644 --- a/frontend/src/views/drug/ShipmentForm.vue +++ b/frontend/src/views/drug/ShipmentForm.vue @@ -116,6 +116,7 @@ import { fetchSites } from "../../api/sites"; import AttachmentList from "../../components/attachments/AttachmentList.vue"; import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; +import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; const route = useRoute(); const router = useRouter(); @@ -134,7 +135,12 @@ const siteActiveMap = computed(() => { const shipmentId = computed(() => route.params.shipmentId as string | undefined); const isEdit = computed(() => !!shipmentId.value); const studyId = computed(() => study.currentStudy?.id || ""); -const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false); +const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); +const canMutate = computed(() => { + const operationKey = isEdit.value ? "drug_shipments:update" : "drug_shipments:create"; + return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]); +}); +const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false)); const form = reactive({ center_id: "", @@ -195,6 +201,10 @@ const load = async () => { }; const submit = async () => { + if (!canMutate.value) { + ElMessage.warning("权限不足"); + return; + } if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; diff --git a/frontend/src/views/fees/ContractFeeDetail.vue b/frontend/src/views/fees/ContractFeeDetail.vue index 858cdaff..6bc580c2 100644 --- a/frontend/src/views/fees/ContractFeeDetail.vue +++ b/frontend/src/views/fees/ContractFeeDetail.vue @@ -127,7 +127,7 @@ const route = useRoute(); const router = useRouter(); const study = useStudyStore(); const { can } = usePermission(); -const canWrite = computed(() => can("fees.contract.write")); +const canWrite = computed(() => can("fees.contract.update")); const contractId = route.params.contractId as string; const loading = ref(false); diff --git a/frontend/src/views/fees/ContractFeeForm.vue b/frontend/src/views/fees/ContractFeeForm.vue index 1128c670..08a6bcda 100644 --- a/frontend/src/views/fees/ContractFeeForm.vue +++ b/frontend/src/views/fees/ContractFeeForm.vue @@ -222,6 +222,7 @@ import StateError from "../../components/StateError.vue"; import StateLoading from "../../components/StateLoading.vue"; import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue"; import { TEXT } from "../../locales"; +import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; const route = useRoute(); const router = useRouter(); @@ -238,8 +239,6 @@ const siteActiveMap = computed(() => { }); return map; }); -const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false); - const formRef = ref(); const form = reactive({ id: "", @@ -258,6 +257,12 @@ const paymentErrors = ref[]>([]); const contractId = computed(() => route.params.contractId as string | undefined); const isEdit = computed(() => !!contractId.value); +const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); +const canMutate = computed(() => { + const operationKey = isEdit.value ? "fees_contracts:update" : "fees_contracts:create"; + return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]); +}); +const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false)); const attachmentGroups = [ { @@ -409,6 +414,10 @@ const validatePayments = () => { const submit = async () => { if (!study.currentStudy?.id) return; + if (!canMutate.value) { + ElMessage.warning("权限不足"); + return; + } if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; diff --git a/frontend/src/views/fees/ContractFees.vue b/frontend/src/views/fees/ContractFees.vue index 3d963e13..d08da22e 100644 --- a/frontend/src/views/fees/ContractFees.vue +++ b/frontend/src/views/fees/ContractFees.vue @@ -80,7 +80,7 @@ {{ TEXT.common.actions.reset }}
- + {{ TEXT.modules.feeContracts.newTitle }} @@ -158,7 +158,7 @@ link type="danger" size="small" - :disabled="!canWrite || isInactiveSite(scope.row.center_id)" + :disabled="!canDelete || isInactiveSite(scope.row.center_id)" @click.stop="remove(scope.row)" > {{ TEXT.common.actions.delete }} @@ -192,7 +192,8 @@ import { TEXT } from "../../locales"; const router = useRouter(); const study = useStudyStore(); const { can } = usePermission(); -const canWrite = computed(() => can("fees.contract.write")); +const canCreate = computed(() => can("fees.contract.create")); +const canDelete = computed(() => can("fees.contract.delete")); const loading = ref(false); const errorMessage = ref(""); @@ -295,7 +296,7 @@ const onRowClick = (row: any) => { }; const remove = async (row: any) => { - if (!row?.id || !canWrite.value) return; + if (!row?.id || !canDelete.value) return; if (isInactiveSite(row?.center_id)) { ElMessage.warning("中心已停用"); return; diff --git a/frontend/src/views/finance/ContractForm.vue b/frontend/src/views/finance/ContractForm.vue index 7e2fb23e..fea9afb1 100644 --- a/frontend/src/views/finance/ContractForm.vue +++ b/frontend/src/views/finance/ContractForm.vue @@ -61,6 +61,7 @@ import { fetchSites } from "../../api/sites"; import AttachmentList from "../../components/attachments/AttachmentList.vue"; import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; +import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; const route = useRoute(); const router = useRouter(); @@ -71,6 +72,11 @@ const sites = ref([]); const contractId = computed(() => route.params.contractId as string | undefined); const isEdit = computed(() => !!contractId.value); const studyId = computed(() => study.currentStudy?.id || ""); +const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); +const canMutate = computed(() => { + const operationKey = isEdit.value ? "finance_contracts:update" : "finance_contracts:create"; + return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]); +}); const form = reactive({ site_name: "", @@ -88,7 +94,7 @@ const siteActiveMap = computed(() => { }); return map; }); -const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false); +const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false)); const loadSites = async () => { if (!studyId.value) return; @@ -119,6 +125,10 @@ const load = async () => { const submit = async () => { if (!studyId.value) return; + if (!canMutate.value) { + ElMessage.warning("权限不足"); + return; + } if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; diff --git a/frontend/src/views/subjects/SubjectForm.vue b/frontend/src/views/subjects/SubjectForm.vue index bb8c082f..6220cb4c 100644 --- a/frontend/src/views/subjects/SubjectForm.vue +++ b/frontend/src/views/subjects/SubjectForm.vue @@ -120,6 +120,7 @@ import { useStudyStore } from "../../store/study"; import { createSubject, getSubject, updateSubject } from "../../api/subjects"; import { fetchSites } from "../../api/sites"; import { TEXT } from "../../locales"; +import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; const route = useRoute(); const router = useRouter(); @@ -137,7 +138,12 @@ const siteActiveMap = computed(() => { const subjectId = computed(() => route.params.subjectId as string | undefined); const isEdit = computed(() => !!subjectId.value); const studyId = computed(() => study.currentStudy?.id || ""); -const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false); +const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); +const canMutate = computed(() => { + const operationKey = isEdit.value ? "subjects:update" : "subjects:create"; + return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]); +}); +const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false)); const form = reactive({ subject_no: "", @@ -180,6 +186,10 @@ const load = async () => { }; const submit = async () => { + if (!canMutate.value) { + ElMessage.warning("权限不足"); + return; + } if (isReadOnly.value) { ElMessage.warning("中心已停用"); return;