优化前后端权限一致性

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) contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") 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) _validate_payment_rules(payment_in)
payment = await payment_crud.create_payment(db, contract_id, payment_in) payment = await payment_crud.create_payment(db, contract_id, payment_in)
await audit_crud.log_action( 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) contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") 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( merged_payment = ContractFeePaymentCreate(
amount=payment_in.amount if payment_in.amount is not None else payment.amount, 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, 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) contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract: if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") 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.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id) await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action( 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" 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: def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role) 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) membership = await member_crud.get_member(db, trial_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") 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) allowed = await role_has_api_permission(db, trial_id, membership.role_in_study, endpoint_key, check_prerequisites=False)
if not allowed: if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
+31 -4
View File
@@ -3,7 +3,7 @@
import pytest import pytest
import uuid import uuid
from pathlib import Path from pathlib import Path
import re import ast
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession 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.core.permission_monitor import PermissionMonitor, set_permission_monitor
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study import Study from app.models.study import Study
from app.models.user import UserRole
from app.schemas.member import StudyMemberCreate from app.schemas.member import StudyMemberCreate
@@ -43,20 +44,46 @@ def test_all_backend_permission_guards_are_configurable():
"""确保后端实际鉴权使用的 operation key 都能在权限管理中配置。""" """确保后端实际鉴权使用的 operation key 都能在权限管理中配置。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
used_keys: set[str] = set() used_keys: set[str] = set()
pattern = re.compile(r"require_api_permission\(\s*[\"']([^\"']+)[\"']")
for path in api_dir.rglob("*.py"): 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
assert used_keys <= set(API_ENDPOINT_PERMISSIONS) 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 @pytest.mark.asyncio
async def test_default_matrix_covers_every_role_and_permission(db_session: AsyncSession): async def test_default_matrix_covers_every_role_and_permission(db_session: AsyncSession):
"""逐一验证 6 个预设角色在每个权限上的默认矩阵。""" """逐一验证预设项目权限角色在每个权限上的默认矩阵。"""
study_id = uuid.uuid4() study_id = uuid.uuid4()
matrix = await get_api_endpoint_permissions(db_session, study_id) 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) assert set(matrix) == set(PROJECT_PERMISSION_ROLES)
for role in PROJECT_PERMISSION_ROLES: for role in PROJECT_PERMISSION_ROLES:
assert set(matrix[role]) == set(API_ENDPOINT_PERMISSIONS) 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.currentStudyRole");
expect(source).toContain("study.currentPermissions"); expect(source).toContain("study.currentPermissions");
expect(source).toContain('"faq.edit": "faq:update"'); 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("isApiPermissionAllowed");
expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]"); expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]");
expect(source).not.toContain("projectPermissions.value?.roles"); expect(source).not.toContain("projectPermissions.value?.roles");
expect(source).not.toContain('"faq.edit": "etmf'); expect(source).not.toContain('"faq.edit": "etmf');
expect(source).not.toContain('"ADMIN", "PM"'); 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"], "project.members.manage": ["ADMIN"],
"site.manage": ["ADMIN"], "site.manage": ["ADMIN"],
"site.cra.bind": ["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> = { const REASONS: Record<string, string> = {
@@ -32,7 +36,11 @@ const REASONS: Record<string, string> = {
"project.members.manage": TEXT.modules.permissions.projectMembersManage, "project.members.manage": TEXT.modules.permissions.projectMembersManage,
"site.manage": TEXT.modules.permissions.siteManage, "site.manage": TEXT.modules.permissions.siteManage,
"site.cra.bind": TEXT.modules.permissions.siteCraBind, "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 = () => { export const usePermission = () => {
@@ -63,7 +71,11 @@ export const usePermission = () => {
"project.members.manage": "project_members:update", "project.members.manage": "project_members:update",
"site.manage": "sites:update", "site.manage": "sites:update",
"site.cra.bind": "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", "fees.attachment.delete": "fees_attachments:delete",
"file.attachment.delete": "attachments:delete", "file.attachment.delete": "attachments:delete",
"audit.export.read": "audit_logs:read", "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/overview")).toEqual({ operationKey: "project_overview:read" });
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" }); expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts: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("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:read" }); expect(getProjectRoutePermission("/drug/shipments/new")).toEqual({ operationKey: "drug_shipments:create" });
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:read" }); 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/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", () => { it("rejects project routes when the role lacks operation permission", () => {
@@ -6,6 +6,13 @@ export type ProjectRoutePermission = {
}; };
const routePermissions: Array<{ prefixes: string[]; permission: 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/overview"], permission: { operationKey: "project_overview:read" } },
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } }, { prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts: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" } }, { 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 = [ export const projectRouteLandingPaths = [
"/project/overview", "/project/overview",
"/project/milestones", "/project/milestones",
@@ -44,6 +63,11 @@ export const projectRouteLandingPaths = [
export const getProjectRoutePermission = (path: string): ProjectRoutePermission | null => { export const getProjectRoutePermission = (path: string): ProjectRoutePermission | null => {
const normalized = path || ""; const normalized = path || "";
for (const item of editRoutePermissions) {
if (item.pattern.test(normalized)) {
return item.permission;
}
}
for (const item of routePermissions) { for (const item of routePermissions) {
if (item.prefixes.some((prefix) => normalized === prefix || normalized.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`))) { if (item.prefixes.some((prefix) => normalized === prefix || normalized.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`))) {
return item.permission; 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> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div> </div>
<div class="filter-spacer"></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> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.newDocument }} {{ TEXT.modules.fileVersionManagement.newDocument }}
</el-button> </el-button>
@@ -75,7 +75,7 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="120" align="center"> <el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="120" align="center">
<template #default="{ row }"> <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 }} {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</template> </template>
@@ -128,6 +128,7 @@ import { fetchDocuments, createDocument, deleteDocument } from "../../api/docume
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { usePermission } from "../../utils/permission";
import type { DocumentSummary } from "../../types/documents"; import type { DocumentSummary } from "../../types/documents";
import type { Site } from "../../types/api"; import type { Site } from "../../types/api";
import { displayEnum, displayText } from "../../utils/display"; import { displayEnum, displayText } from "../../utils/display";
@@ -137,6 +138,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
const { can } = usePermission();
const loading = ref(false); const loading = ref(false);
const items = ref<DocumentSummary[]>([]); const items = ref<DocumentSummary[]>([]);
const sites = ref<Site[]>([]); const sites = ref<Site[]>([]);
@@ -173,6 +175,8 @@ const siteActiveMap = computed(() => {
const isAdmin = computed(() => { const isAdmin = computed(() => {
return !!auth.user?.is_admin; 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 isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
const documentRowClass = ({ row }: { row: DocumentSummary }) => const documentRowClass = ({ row }: { row: DocumentSummary }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim(); `${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
@@ -259,6 +263,10 @@ const resetFilters = () => {
}; };
const openCreate = () => { const openCreate = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
createForm.doc_no = ""; createForm.doc_no = "";
createForm.title = ""; createForm.title = "";
createForm.scope_type = "GLOBAL"; createForm.scope_type = "GLOBAL";
@@ -268,6 +276,10 @@ const openCreate = () => {
}; };
const submitCreate = async () => { const submitCreate = async () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
if (!createFormRef.value) return; if (!createFormRef.value) return;
await createFormRef.value.validate(async (valid) => { await createFormRef.value.validate(async (valid) => {
if (!valid || !trialId.value) return; if (!valid || !trialId.value) return;
@@ -305,6 +317,10 @@ const handleRowClick = (row: DocumentSummary) => {
}; };
const confirmDelete = async (row: DocumentSummary) => { const confirmDelete = async (row: DocumentSummary) => {
if (!canDelete.value) {
ElMessage.warning("权限不足");
return;
}
if (!row?.id) return; if (!row?.id) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return; if (!ok) return;
+11 -1
View File
@@ -116,6 +116,7 @@ import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -134,7 +135,12 @@ const siteActiveMap = computed(() => {
const shipmentId = computed(() => route.params.shipmentId as string | undefined); const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const isEdit = computed(() => !!shipmentId.value); const isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || ""); 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({ const form = reactive({
center_id: "", center_id: "",
@@ -195,6 +201,10 @@ const load = async () => {
}; };
const submit = async () => { const submit = async () => {
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
@@ -127,7 +127,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); 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 contractId = route.params.contractId as string;
const loading = ref(false); 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 StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue"; import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -238,8 +239,6 @@ const siteActiveMap = computed(() => {
}); });
return map; return map;
}); });
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const formRef = ref(); const formRef = ref();
const form = reactive({ const form = reactive({
id: "", id: "",
@@ -258,6 +257,12 @@ const paymentErrors = ref<Record<string, string>[]>([]);
const contractId = computed(() => route.params.contractId as string | undefined); const contractId = computed(() => route.params.contractId as string | undefined);
const isEdit = computed(() => !!contractId.value); 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 = [ const attachmentGroups = [
{ {
@@ -409,6 +414,10 @@ const validatePayments = () => {
const submit = async () => { const submit = async () => {
if (!study.currentStudy?.id) return; if (!study.currentStudy?.id) return;
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
+5 -4
View File
@@ -80,7 +80,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div> </div>
<div class="filter-spacer"></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> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeContracts.newTitle }} {{ TEXT.modules.feeContracts.newTitle }}
</el-button> </el-button>
@@ -158,7 +158,7 @@
link link
type="danger" type="danger"
size="small" size="small"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)" :disabled="!canDelete || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)" @click.stop="remove(scope.row)"
> >
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
@@ -192,7 +192,8 @@ import { TEXT } from "../../locales";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const { can } = usePermission(); 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 loading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
@@ -295,7 +296,7 @@ const onRowClick = (row: any) => {
}; };
const remove = async (row: any) => { const remove = async (row: any) => {
if (!row?.id || !canWrite.value) return; if (!row?.id || !canDelete.value) return;
if (isInactiveSite(row?.center_id)) { if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
+11 -1
View File
@@ -61,6 +61,7 @@ import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -71,6 +72,11 @@ const sites = ref<any[]>([]);
const contractId = computed(() => route.params.contractId as string | undefined); const contractId = computed(() => route.params.contractId as string | undefined);
const isEdit = computed(() => !!contractId.value); const isEdit = computed(() => !!contractId.value);
const studyId = computed(() => study.currentStudy?.id || ""); 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({ const form = reactive({
site_name: "", site_name: "",
@@ -88,7 +94,7 @@ const siteActiveMap = computed(() => {
}); });
return map; 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 () => { const loadSites = async () => {
if (!studyId.value) return; if (!studyId.value) return;
@@ -119,6 +125,10 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;
+11 -1
View File
@@ -120,6 +120,7 @@ import { useStudyStore } from "../../store/study";
import { createSubject, getSubject, updateSubject } from "../../api/subjects"; import { createSubject, getSubject, updateSubject } from "../../api/subjects";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -137,7 +138,12 @@ const siteActiveMap = computed(() => {
const subjectId = computed(() => route.params.subjectId as string | undefined); const subjectId = computed(() => route.params.subjectId as string | undefined);
const isEdit = computed(() => !!subjectId.value); const isEdit = computed(() => !!subjectId.value);
const studyId = computed(() => study.currentStudy?.id || ""); 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({ const form = reactive({
subject_no: "", subject_no: "",
@@ -180,6 +186,10 @@ const load = async () => {
}; };
const submit = async () => { const submit = async () => {
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) { if (isReadOnly.value) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
return; return;