优化前后端权限一致性

This commit is contained in:
Cheng Zhou
2026-05-26 14:53:17 +08:00
parent 41bd423be0
commit c909fc9387
15 changed files with 184 additions and 26 deletions
+3 -3
View File
@@ -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(
+9 -1
View File
@@ -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="权限不足")
+31 -4
View File
@@ -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)
+6
View File
@@ -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"');
});
});
+15 -3
View File
@@ -18,7 +18,11 @@ const PERMISSIONS: Record<string, string[]> = {
"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<string, string> = {
@@ -32,7 +36,11 @@ const REASONS: Record<string, string> = {
"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",
@@ -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", () => {
@@ -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;
@@ -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\"");
});
});
+18 -2
View File
@@ -22,7 +22,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" @click="openCreate" class="header-action-btn">
<el-button type="primary" :disabled="!canCreate" @click="openCreate" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.newDocument }}
</el-button>
@@ -75,7 +75,7 @@
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="120" align="center">
<template #default="{ row }">
<el-button v-if="isAdmin" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
<el-button v-if="canDelete" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
</template>
@@ -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<DocumentSummary[]>([]);
const sites = ref<Site[]>([]);
@@ -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;
+11 -1
View File
@@ -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;
@@ -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);
+11 -2
View File
@@ -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<Record<string, string>[]>([]);
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;
+5 -4
View File
@@ -80,7 +80,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-button type="primary" :disabled="!canCreate" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeContracts.newTitle }}
</el-button>
@@ -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;
+11 -1
View File
@@ -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<any[]>([]);
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;
+11 -1
View File
@@ -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;