优化文档管理与导航体验

1、为文档增加编辑接口和前端编辑抽屉,文档列表与详情页统一按创建、更新、删除权限展示操作。

2、将文档版本上传和分发改为抽屉交互,并在详情页补充版本删除权限检查。

3、增强全局面包屑上下文和详情页标题同步,避免详情页重复或缺失导航信息。

4、按项目权限过滤快捷入口、首页费用指标和 eTMF 新建入口,并补充对应前端测试。
This commit is contained in:
Cheng Zhou
2026-06-04 11:11:28 +08:00
parent 6e8494abd5
commit 90ca061db8
21 changed files with 1016 additions and 189 deletions
+12 -1
View File
@@ -9,7 +9,7 @@ from app.core.deps import get_current_user, get_db_session
from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead
from app.schemas.common import PaginatedResponse from app.schemas.common import PaginatedResponse
from app.schemas.distribution import DistributionCreate, DistributionRead from app.schemas.distribution import DistributionCreate, DistributionRead
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
from app.schemas.document_version import DocumentVersionRead from app.schemas.document_version import DocumentVersionRead
from app.services import document_service from app.services import document_service
from app.utils.pagination import paginate from app.utils.pagination import paginate
@@ -66,6 +66,17 @@ async def get_document_detail(
return await document_service.get_document_detail(db, document_id, current_user) return await document_service.get_document_detail(db, document_id, current_user)
@router.patch("/documents/{document_id}", response_model=DocumentSummary)
async def update_document(
document_id: uuid.UUID,
payload: DocumentUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
doc = await document_service.update_document(db, document_id, payload, current_user)
return DocumentSummary.model_validate(doc)
@router.delete("/documents/{document_id}", response_model=DocumentSummary) @router.delete("/documents/{document_id}", response_model=DocumentSummary)
async def delete_document( async def delete_document(
document_id: uuid.UUID, document_id: uuid.UUID,
+52 -1
View File
@@ -32,7 +32,7 @@ from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus from app.models.document_version import DocumentVersion, DocumentVersionStatus
from app.schemas.acknowledgement import AcknowledgementCreate from app.schemas.acknowledgement import AcknowledgementCreate
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
from app.schemas.notification import NotificationItem from app.schemas.notification import NotificationItem
from app.schemas.user import UserDisplay from app.schemas.user import UserDisplay
@@ -43,6 +43,7 @@ DOCUMENT_ACTION_PERMISSIONS = {
"view": "documents:read", "view": "documents:read",
"ack": "documents:read", "ack": "documents:read",
"create_document": "documents:create", "create_document": "documents:create",
"update_document": "documents:update",
"create_version": "documents:update", "create_version": "documents:update",
"distribute": "documents:update", "distribute": "documents:update",
"delete_document": "documents:delete", "delete_document": "documents:delete",
@@ -261,6 +262,56 @@ async def get_document_detail(
) )
async def update_document(
db: AsyncSession,
document_id: uuid.UUID,
doc_in: DocumentUpdate,
current_user,
) -> Document:
doc = await document_crud.get(db, document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="update_document")
values = doc_in.model_dump(exclude_unset=True)
if not values:
return doc
next_scope = values.get("scope_type", doc.scope_type)
next_scope_value = getattr(next_scope, "value", next_scope)
next_site_id = values.get("site_id", doc.site_id)
if next_scope_value == DocumentScopeType.SITE.value:
if not next_site_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心")
site = await site_crud.get_site(db, next_site_id)
if not site or site.study_id != doc.trial_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不属于当前项目")
if not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心已停用")
else:
values["site_id"] = None
before = _doc_snapshot(doc)
await document_crud.update(db, document_id, values, commit=False)
updated = await document_crud.get(db, document_id)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
db.add(
AuditLog(
study_id=updated.trial_id,
entity_type="DOCUMENT",
entity_id=updated.id,
action="DOCUMENT_UPDATED",
detail=_audit_detail(before, _doc_snapshot(updated)),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, updated.trial_id, current_user),
)
)
await db.commit()
await db.refresh(updated)
return updated
async def create_version( async def create_version(
db: AsyncSession, db: AsyncSession,
document_id: uuid.UUID, document_id: uuid.UUID,
+4 -1
View File
@@ -1,4 +1,4 @@
import { apiDelete, apiGet, apiPost } from "./axios"; import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api"; import type { ApiListResponse } from "../types/api";
import type { import type {
Acknowledgement, Acknowledgement,
@@ -14,6 +14,9 @@ export const fetchDocuments = (params: Record<string, any>) =>
export const createDocument = (payload: Record<string, any>) => export const createDocument = (payload: Record<string, any>) =>
apiPost<DocumentSummary>("/api/v1/documents", payload); apiPost<DocumentSummary>("/api/v1/documents", payload);
export const updateDocument = (documentId: string, payload: Record<string, any>) =>
apiPatch<DocumentSummary>(`/api/v1/documents/${documentId}`, payload);
export const deleteDocument = (documentId: string) => export const deleteDocument = (documentId: string) =>
apiDelete<DocumentSummary>(`/api/v1/documents/${documentId}`); apiDelete<DocumentSummary>(`/api/v1/documents/${documentId}`);
+22 -3
View File
@@ -120,7 +120,7 @@
<!-- 移入 Header 的面包屑 --> <!-- 移入 Header 的面包屑 -->
<div v-if="breadcrumbs.length" class="header-breadcrumb"> <div v-if="breadcrumbs.length" class="header-breadcrumb">
<template v-for="(item, index) in breadcrumbs" :key="index"> <template v-for="(item, index) in breadcrumbs" :key="breadcrumbKey(item, index)">
<!-- 带下拉的选择器类型 --> <!-- 带下拉的选择器类型 -->
<el-dropdown v-if="item.hasDropdown" trigger="click" @command="handleBreadcrumbCommand"> <el-dropdown v-if="item.hasDropdown" trigger="click" @command="handleBreadcrumbCommand">
<div class="breadcrumb-item-wrapper is-link"> <div class="breadcrumb-item-wrapper is-link">
@@ -195,7 +195,7 @@
<div class="content-wrapper"> <div class="content-wrapper">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<div class="ctms-route-shell"> <div class="ctms-route-shell">
<component :is="Component" /> <component v-if="Component" :is="Component" />
</div> </div>
</router-view> </router-view>
</div> </div>
@@ -310,6 +310,12 @@ const breadcrumbs = computed(() => {
label: TEXT.menu.admin, label: TEXT.menu.admin,
path: "/admin/users" path: "/admin/users"
}); });
if (route.path.startsWith("/admin/projects/")) {
items.push({
label: TEXT.menu.projectManagement,
path: "/admin/projects"
});
}
} else if (study.currentStudy) { } else if (study.currentStudy) {
// 1. 项目名 // 1. 项目名
items.push({ items.push({
@@ -343,6 +349,7 @@ const breadcrumbs = computed(() => {
"/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" }, "/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
"/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" }, "/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" },
"/knowledge/precautions": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" }, "/knowledge/precautions": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" },
"/documents": { label: TEXT.menu.fileVersionManagement, path: study.currentStudy?.id ? `/trial/${study.currentStudy.id}/documents` : "/file-versions" },
}; };
let matchedModule = null; let matchedModule = null;
@@ -365,8 +372,9 @@ const breadcrumbs = computed(() => {
} }
// 4. 当前页面标题 // 4. 当前页面标题
const hasContextPageTitle = Boolean(study.viewContext?.pageTitle);
const title = route.meta?.title as string | undefined; const title = route.meta?.title as string | undefined;
if (title && title !== study.currentStudy?.name && title !== TEXT.menu.projectOverview && title !== (study.viewContext?.siteName || study.currentSite?.name)) { if (!hasContextPageTitle && title && title !== study.currentStudy?.name && title !== TEXT.menu.projectOverview && title !== (study.viewContext?.siteName || study.currentSite?.name)) {
// 检查 title 是否已经作为模块名添加过了 // 检查 title 是否已经作为模块名添加过了
const isModuleTitle = items.some(item => item.label === title); const isModuleTitle = items.some(item => item.label === title);
if (!isModuleTitle) { if (!isModuleTitle) {
@@ -377,9 +385,20 @@ const breadcrumbs = computed(() => {
} }
} }
if (study.viewContext?.pageTitle) {
items.push({ label: study.viewContext.pageTitle, path: route.path });
}
return items; return items;
}); });
const breadcrumbKey = (item: any, index: number) => {
const type = item.type || "item";
const path = item.path || "";
const label = item.label || "";
return `${index}:${type}:${path}:${label}`;
};
const handleBreadcrumbCommand = async (cmd: any) => { const handleBreadcrumbCommand = async (cmd: any) => {
if (cmd.type === 'study') { if (cmd.type === 'study') {
study.setCurrentStudy(cmd.value); study.setCurrentStudy(cmd.value);
@@ -0,0 +1,44 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readLayout = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
describe("Layout breadcrumbs", () => {
it("shows the file version module before document detail titles", () => {
const source = readLayout();
expect(source).toContain('"/documents": { label: TEXT.menu.fileVersionManagement');
expect(source).toContain("study.viewContext?.pageTitle");
expect(source).toContain("items.push({ label: study.viewContext.pageTitle, path: route.path })");
});
it("uses context page titles instead of generic route detail titles", () => {
const source = readLayout();
expect(source).toContain("const hasContextPageTitle = Boolean(study.viewContext?.pageTitle)");
expect(source).toContain("if (!hasContextPageTitle && title");
});
it("keeps project management as the parent for admin project detail breadcrumbs", () => {
const source = readLayout();
expect(source).toContain('if (route.path.startsWith("/admin/projects/"))');
expect(source).toContain("label: TEXT.menu.projectManagement");
expect(source).toContain('path: "/admin/projects"');
});
it("uses stable breadcrumb keys when context titles change", () => {
const source = readLayout();
expect(source).toContain(':key="breadcrumbKey(item, index)"');
expect(source).not.toContain(':key="index"');
expect(source).toContain("const breadcrumbKey = (item: any, index: number)");
});
it("guards route component rendering while router resolves async views", () => {
const source = readLayout();
expect(source).toContain('<component v-if="Component" :is="Component" />');
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readQuickActionsSource = () => readFileSync(resolve(__dirname, "./QuickActions.vue"), "utf8");
describe("QuickActions project permissions", () => {
it("filters quick action entries with the same route permission contract as sidebar and router", () => {
const source = readQuickActionsSource();
expect(source).toContain("useAuthStore");
expect(source).toContain("useStudyStore");
expect(source).toContain("getProjectRoutePermission");
expect(source).toContain("hasProjectPermission");
expect(source).toContain("isSystemAdmin");
expect(source).toContain("visibleActions");
expect(source).toContain("v-for=\"item in visibleActions\"");
expect(source).not.toContain("v-for=\"item in actions\"");
});
});
+20 -1
View File
@@ -6,7 +6,7 @@
</div> </div>
<div class="actions-grid"> <div class="actions-grid">
<div <div
v-for="item in actions" v-for="item in visibleActions"
:key="item.path" :key="item.path"
class="action-item" class="action-item"
@click="go(item.path)" @click="go(item.path)"
@@ -22,14 +22,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { import {
Pointer, ArrowRight, Timer, UserFilled, Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection Management, Money, Collection
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
const router = useRouter(); const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const actions = [ const actions = [
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer }, { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
@@ -40,6 +47,18 @@ const actions = [
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", icon: Collection }, { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", icon: Collection },
]; ];
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const visibleActions = computed(() =>
actions.filter((item) =>
hasProjectPermission(
study.currentPermissions,
projectRole.value,
getProjectRoutePermission(item.path),
isSystemAdmin(auth.user),
)
)
);
const go = (path: string) => router.push(path); const go = (path: string) => router.push(path);
</script> </script>
+2
View File
@@ -165,6 +165,8 @@ body {
/* 表格美化 */ /* 表格美化 */
.el-table { .el-table {
color: var(--ctms-text-regular); color: var(--ctms-text-regular);
width: 100%;
max-width: 100%;
} }
.el-table th.el-table__cell { .el-table th.el-table__cell {
+16
View File
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readMainCss = () => readFileSync(resolve(__dirname, "./main.css"), "utf8");
describe("global table styles", () => {
it("keeps Element Plus table sizing on the root node without overriding internal layout", () => {
const css = readMainCss();
expect(css).toContain(".el-table {\n color: var(--ctms-text-regular);\n width: 100%;\n max-width: 100%;\n}");
expect(css).not.toContain(".el-table__header,\n.el-table__body,\n.el-table__footer");
expect(css).not.toContain(".el-table__inner-wrapper,\n.el-table__header-wrapper");
expect(css).not.toContain(".el-table colgroup col");
});
});
+10
View File
@@ -14,4 +14,14 @@ describe("StudyHome role labels", () => {
expect(source).toContain("loadRoleTemplates();"); expect(source).toContain("loadRoleTemplates();");
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, projectRole.value)"); expect(source).not.toContain("displayEnum(TEXT.enums.userRole, projectRole.value)");
}); });
it("gates the finance KPI by the contract fee read permission", () => {
const source = readSource();
expect(source).toContain('v-if="canReadFinanceContracts"');
expect(source).toContain('const canReadFinanceContracts = computed');
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('study.currentPermissions?.[role]?.["fees_contracts:read"]');
expect(source).toContain('canReadFinanceContracts.value ? fetchFinanceSummary(studyId) : Promise.resolve(null)');
});
}); });
+11 -4
View File
@@ -38,7 +38,7 @@
:icon="Warning" :icon="Warning"
/> />
</div> </div>
<div class="stats-item"> <div v-if="canReadFinanceContracts" class="stats-item">
<KpiCard <KpiCard
:title="TEXT.modules.projectOverview.kpiFinanceTotal" :title="TEXT.modules.projectOverview.kpiFinanceTotal"
:value="formatAmount(financeSummary?.total_amount ?? 0)" :value="formatAmount(financeSummary?.total_amount ?? 0)"
@@ -116,6 +116,7 @@ import QuickActions from "../components/QuickActions.vue";
import { List, Warning, Money } from "@element-plus/icons-vue"; import { List, Warning, Money } from "@element-plus/icons-vue";
import StateEmpty from "../components/StateEmpty.vue"; import StateEmpty from "../components/StateEmpty.vue";
import { getProjectRole, isSystemAdmin } from "../utils/roles"; import { getProjectRole, isSystemAdmin } from "../utils/roles";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta"; import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import type { NotificationItem } from "../types/notifications"; import type { NotificationItem } from "../types/notifications";
@@ -150,6 +151,12 @@ const projectRole = computed(() => {
return getProjectRole(study.currentStudy, study.currentStudyRole); return getProjectRole(study.currentStudy, study.currentStudyRole);
}); });
const roleLabel = computed(() => displayRoleLabel(projectRole.value)); const roleLabel = computed(() => displayRoleLabel(projectRole.value));
const canReadFinanceContracts = computed(() => {
if (isSystemAdmin(auth.user)) return true;
const role = projectRole.value;
if (!role) return false;
return isApiPermissionAllowed(study.currentPermissions?.[role]?.["fees_contracts:read"]);
});
const formatAmount = (val: number) => { const formatAmount = (val: number) => {
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val); return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val);
@@ -160,19 +167,19 @@ const loadData = async () => {
if (!studyId) return; if (!studyId) return;
loading.value.progress = true; loading.value.progress = true;
loading.value.finance = true; loading.value.finance = canReadFinanceContracts.value;
loading.value.overdueAes = true; loading.value.overdueAes = true;
loading.value.notifications = true; loading.value.notifications = true;
try { try {
const [pRes, fRes, aesRes, nRes] = await Promise.allSettled([ const [pRes, fRes, aesRes, nRes] = await Promise.allSettled([
fetchProgress(studyId), fetchProgress(studyId),
fetchFinanceSummary(studyId), canReadFinanceContracts.value ? fetchFinanceSummary(studyId) : Promise.resolve(null),
fetchOverdueAesCount(studyId), fetchOverdueAesCount(studyId),
listNotifications(studyId, { limit: 5 }), listNotifications(studyId, { limit: 5 }),
]); ]);
if (pRes.status === "fulfilled") progress.value = pRes.value.data; if (pRes.status === "fulfilled") progress.value = pRes.value.data;
if (fRes.status === "fulfilled") financeSummary.value = fRes.value.data; if (fRes.status === "fulfilled" && fRes.value) financeSummary.value = fRes.value.data;
if (aesRes.status === "fulfilled") overdueAes.value = aesRes.value.data?.total ?? 0; if (aesRes.status === "fulfilled") overdueAes.value = aesRes.value.data?.total ?? 0;
if (nRes.status === "fulfilled") notifications.value = nRes.value.data || []; if (nRes.status === "fulfilled") notifications.value = nRes.value.data || [];
} finally { } finally {
@@ -0,0 +1,66 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readView = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
describe("detail breadcrumb contexts", () => {
it("uses contract numbers for contract fee detail breadcrumbs", () => {
const source = readView("./fees/ContractFeeDetail.vue");
expect(source).toContain("study.setViewContext({");
expect(source).toContain("siteName");
expect(source).toContain("pageTitle: detail.contract_no || TEXT.menu.feeContracts");
});
it("uses shipment tracking or batch numbers for drug shipment detail breadcrumbs", () => {
const source = readView("./drug/ShipmentDetail.vue");
expect(source).toContain("study.setViewContext({");
expect(source).toContain("siteName: detail.site_name || TEXT.common.fallback");
expect(source).toContain("pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle");
});
it("uses equipment names for material equipment detail breadcrumbs", () => {
const source = readView("./materials/MaterialEquipmentDetail.vue");
expect(source).toContain("study.setViewContext({");
expect(source).toContain("pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle");
});
it("uses subject numbers and centers for subject detail breadcrumbs", () => {
const source = readView("./subjects/SubjectDetail.vue");
expect(source).toContain("study.setViewContext({");
expect(source).toContain("siteName: siteMap.value[detail.site_id] || TEXT.common.fallback");
expect(source).toContain("pageTitle: detail.subject_no || TEXT.modules.subjectManagement.screeningNo");
});
it("uses startup record identifiers for startup detail breadcrumbs", () => {
const feasibility = readView("./startup/FeasibilityDetail.vue");
const ethics = readView("./startup/EthicsDetail.vue");
const kickoff = readView("./startup/KickoffDetail.vue");
const training = readView("./startup/TrainingDetail.vue");
expect(feasibility).toContain("pageTitle: detail.project_no ||");
expect(ethics).toContain("pageTitle: detail.approval_no ||");
expect(kickoff).toContain("pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}`");
expect(training).toContain("pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle");
});
it("uses knowledge item titles for knowledge detail breadcrumbs", () => {
const precaution = readView("./knowledge/PrecautionDetail.vue");
const faq = readView("./FaqDetail.vue");
expect(precaution).toContain("pageTitle: detail.title || TEXT.modules.knowledgeNotes.detailTitle");
expect(faq).toContain("const questionTitle = String(faqData.question || \"\").trim()");
expect(faq).toContain("pageTitle: questionTitle ? questionTitle.slice(0, 24) : TEXT.modules.knowledgeMedicalConsult.detailTitle");
});
it("uses project names for admin project detail breadcrumbs", () => {
const source = readView("./admin/ProjectDetail.vue");
expect(source).toContain("studyStore.setViewContext({");
expect(source).toContain("pageTitle: project.value.name || TEXT.modules.adminProjects.detailTitle");
});
});
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readView = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
const detailViews = [
"./fees/ContractFeeDetail.vue",
"./drug/ShipmentDetail.vue",
"./materials/MaterialEquipmentDetail.vue",
"./documents/DocumentDetail.vue",
"./subjects/SubjectDetail.vue",
"./knowledge/PrecautionDetail.vue",
"./startup/FeasibilityDetail.vue",
"./startup/EthicsDetail.vue",
"./startup/KickoffDetail.vue",
"./startup/TrainingDetail.vue",
];
describe("detail page navigation", () => {
it("uses header breadcrumbs instead of page-local back actions", () => {
for (const viewPath of detailViews) {
const source = readView(viewPath);
expect(source, viewPath).not.toContain("TEXT.common.actions.back");
expect(source, viewPath).not.toContain('@click="goBack"');
expect(source, viewPath).not.toContain("const goBack =");
expect(source, viewPath).not.toContain("function goBack");
}
});
});
@@ -18,3 +18,62 @@ describe("DocumentDetail role labels", () => {
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, m.role_in_study)"); expect(source).not.toContain("displayEnum(TEXT.enums.userRole, m.role_in_study)");
}); });
}); });
describe("DocumentDetail permissions", () => {
it("guards version mutation and distribution actions with project document permissions", () => {
const source = readSource();
expect(source).toContain('can("documents.update")');
expect(source).toContain('can("documents.delete")');
expect(source).toContain('v-if="canUpdateDocument"');
expect(source).toContain('v-if="canDeleteDocument"');
expect(source).toContain("if (!canUpdateDocument.value)");
expect(source).toContain("if (!canDeleteDocument.value)");
expect(source).not.toContain("if (list.length === 0) return true;");
expect(source).not.toContain('v-if="isAdmin"');
});
it("shows a detail edit action and guards document metadata updates with update permission", () => {
const source = readSource();
expect(source).toContain("<el-icon class=\"el-icon--left\"><Edit /></el-icon>");
expect(source).toContain("@click=\"openEdit\"");
expect(source).toContain("const editorVisible = ref(false)");
expect(source).toContain("updateDocument");
expect(source).toContain("if (!canUpdateDocument.value)");
expect(source).toContain("await loadDetail()");
});
it("does not load project members unless the role can read project members", () => {
const source = readSource();
expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (!canReadMembers.value)");
});
});
describe("DocumentDetail breadcrumbs", () => {
it("syncs the document title and scope into the header breadcrumb context", () => {
const source = readSource();
expect(source).toContain("useStudyStore");
expect(source).toContain("study.setViewContext({");
expect(source).toContain("siteName: displaySite(detail.site_id)");
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
});
});
describe("DocumentDetail editor overlays", () => {
it("uses right-side drawers for upload and distribution forms", () => {
const source = readSource();
expect(source).toContain("upload-editor-drawer");
expect(source).toContain("distribution-editor-drawer");
expect(source).toContain('direction="rtl"');
expect(source).toContain(':show-close="false"');
expect(source).toContain("editor-header");
expect(source).toContain("drawer-footer");
expect(source).not.toContain("<el-dialog append-to=\".layout-main .content-wrapper\" v-model=\"uploadVisible\"");
expect(source).not.toContain("<el-dialog append-to=\".layout-main .content-wrapper\" v-model=\"distributeVisible\"");
});
});
+347 -101
View File
@@ -17,10 +17,10 @@
</span> </span>
</div> </div>
</div> </div>
<div class="ctms-section-actions"> <div class="actions">
<el-button @click="goBack"> <el-button v-if="canUpdateDocument" type="primary" @click="openEdit" class="header-action-btn">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon> <el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.back }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -43,7 +43,7 @@
<div class="ctms-section-header"> <div class="ctms-section-header">
<div class="ctms-section-title">&nbsp;</div> <div class="ctms-section-title">&nbsp;</div>
<div class="ctms-section-actions"> <div class="ctms-section-actions">
<el-button type="primary" @click="openUpload" v-if="canAction('create_version')"> <el-button type="primary" @click="openUpload" v-if="canUpdateDocument">
<el-icon class="el-icon--left"><Upload /></el-icon> <el-icon class="el-icon--left"><Upload /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.uploadVersion }} {{ TEXT.modules.fileVersionManagement.actions.uploadVersion }}
</el-button> </el-button>
@@ -79,11 +79,11 @@
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="140" align="center"> <el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="140" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canAction('view')"> <el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
{{ TEXT.modules.fileVersionManagement.actions.download }} {{ TEXT.modules.fileVersionManagement.actions.download }}
</el-button> </el-button>
<el-button <el-button
v-if="isAdmin" v-if="canDeleteDocument"
link link
type="danger" type="danger"
size="small" size="small"
@@ -101,7 +101,7 @@
<div class="ctms-section-header"> <div class="ctms-section-header">
<div class="ctms-section-title">&nbsp;</div> <div class="ctms-section-title">&nbsp;</div>
<div class="ctms-section-actions"> <div class="ctms-section-actions">
<el-button type="primary" @click="openDistribute"> <el-button type="primary" @click="openDistribute" v-if="canUpdateDocument">
<el-icon class="el-icon--left"><Share /></el-icon> <el-icon class="el-icon--left"><Share /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.distribute }} {{ TEXT.modules.fileVersionManagement.actions.distribute }}
</el-button> </el-button>
@@ -132,100 +132,199 @@
</section> </section>
</div> </div>
<el-dialog append-to=".layout-main .content-wrapper" v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog"> <el-drawer
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="110px" class="ctms-form"> v-if="editorVisible"
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no"> v-model="editorVisible"
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" /> direction="rtl"
</el-form-item> size="620px"
<el-form-item label="版本日期" prop="version_date"> :close-on-click-modal="true"
<el-date-picker :show-close="false"
v-model="uploadForm.version_date" destroy-on-close
type="date" class="document-editor-drawer"
value-format="YYYY-MM-DD" >
:placeholder="TEXT.common.placeholders.select" <template #header>
style="width: 100%" <div class="editor-header">
/> <div class="editor-title">{{ TEXT.common.actions.edit }}</div>
</el-form-item> </div>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.changeSummary"> </template>
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" /> <el-form ref="editorFormRef" :model="editorForm" :rules="editorRules" label-position="top" class="ctms-form">
</el-form-item> <div class="form-group">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.parentVersion"> <div class="form-group-title">
<el-select v-model="uploadForm.parent_version_id" clearable style="width: 100%"> <span class="group-dot group-dot-basic"></span>
<el-option {{ TEXT.common.labels.basicInfo }}
v-for="item in detail.version_timeline" </div>
:key="item.id" <el-form-item :label="TEXT.modules.fileVersionManagement.fields.title" prop="title">
:label="item.version_no" <el-input v-model="editorForm.title" />
:value="item.id" </el-form-item>
/> <el-row :gutter="16">
</el-select> <el-col :span="12">
</el-form-item> <el-form-item :label="TEXT.modules.fileVersionManagement.fields.scope" prop="scope_type">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.file"> <el-select v-model="editorForm.scope_type" class="full-width">
<input type="file" @change="onFileChange" class="ctms-file-input" /> <el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-form-item> </el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
<el-select v-model="editorForm.doc_type" class="full-width">
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="editorForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
<el-select v-model="editorForm.site_id" filterable class="full-width">
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" />
</el-select>
</el-form-item>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="uploadVisible = false">{{ TEXT.common.actions.cancel }}</el-button> <div class="drawer-footer">
<el-button type="primary" :loading="uploading" @click="submitUpload">{{ TEXT.common.actions.confirm }}</el-button> <el-button @click="editorVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="savingEditor" @click="submitEditor">{{ TEXT.common.actions.confirm }}</el-button>
</div>
</template> </template>
</el-dialog> </el-drawer>
<el-dialog append-to=".layout-main .content-wrapper" v-model="distributeVisible" :title="TEXT.modules.fileVersionManagement.dialog.distributeTitle" width="600px" destroy-on-close class="ctms-dialog"> <el-drawer
<el-form :model="distributeForm" label-width="100px" class="ctms-form"> v-if="uploadVisible"
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList"> v-model="uploadVisible"
<div class="target-list w-full"> direction="rtl"
<div class="target-toolbar"> size="620px"
<el-select v-model="distributeForm.target_type" class="target-type-preset" :placeholder="TEXT.common.labels.all" @change="onTargetTypeChange"> :close-on-click-modal="true"
<el-option :label="TEXT.common.labels.all" value="ALL" /> :show-close="false"
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" /> destroy-on-close
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" /> class="upload-editor-drawer"
</el-select> >
</div> <template #header>
<div class="target-row"> <div class="editor-header">
<template v-if="distributeForm.target_type === 'ROLE'"> <div class="editor-title">{{ TEXT.modules.fileVersionManagement.dialog.uploadTitle }}</div>
<el-select </div>
v-model="distributeForm.role_ids" </template>
multiple <el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-position="top" class="ctms-form">
filterable <div class="form-group">
clearable <div class="form-group-title">
:placeholder="TEXT.common.placeholders.select" <span class="group-dot group-dot-basic"></span>
class="target-select" {{ TEXT.modules.fileVersionManagement.actions.uploadVersion }}
> </div>
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" /> <el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no">
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
</el-form-item>
<el-form-item label="版本日期" prop="version_date">
<el-date-picker
v-model="uploadForm.version_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.changeSummary">
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.parentVersion">
<el-select v-model="uploadForm.parent_version_id" clearable style="width: 100%">
<el-option
v-for="item in detail.version_timeline"
:key="item.id"
:label="item.version_no"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.file">
<input type="file" @change="onFileChange" class="ctms-file-input" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="uploadVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">{{ TEXT.common.actions.confirm }}</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-if="distributeVisible"
v-model="distributeVisible"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:show-close="false"
destroy-on-close
class="distribution-editor-drawer"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ TEXT.modules.fileVersionManagement.dialog.distributeTitle }}</div>
</div>
</template>
<el-form :model="distributeForm" label-position="top" class="ctms-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.fileVersionManagement.fields.targetList }}
</div>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
<div class="target-list w-full">
<div class="target-toolbar">
<el-select v-model="distributeForm.target_type" class="target-type-preset" :placeholder="TEXT.common.labels.all" @change="onTargetTypeChange">
<el-option :label="TEXT.common.labels.all" value="ALL" />
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" />
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" />
</el-select> </el-select>
</template> </div>
<template v-else-if="distributeForm.target_type === 'USER'"> <div class="target-row">
<el-select <template v-if="distributeForm.target_type === 'ROLE'">
v-model="distributeForm.user_ids" <el-select
multiple v-model="distributeForm.role_ids"
filterable multiple
clearable filterable
:placeholder="TEXT.common.placeholders.select" clearable
class="target-select" :placeholder="TEXT.common.placeholders.select"
> class="target-select"
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" /> >
</el-select> <el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
</template> </el-select>
<div v-else class="target-all"> </template>
<el-tag effect="plain" type="info">{{ TEXT.common.labels.all }}</el-tag> <template v-else-if="distributeForm.target_type === 'USER'">
<el-select
v-model="distributeForm.user_ids"
multiple
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
</el-select>
</template>
<div v-else class="target-all">
<el-tag effect="plain" type="info">{{ TEXT.common.labels.all }}</el-tag>
</div>
</div> </div>
</div> </div>
</div> </el-form-item>
</el-form-item> </div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="distributeVisible = false">{{ TEXT.common.actions.cancel }}</el-button> <div class="drawer-footer">
<el-button type="primary" :loading="distributing" @click="submitDistribute">{{ TEXT.common.actions.confirm }}</el-button> <el-button @click="distributeVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="distributing" @click="submitDistribute">{{ TEXT.common.actions.confirm }}</el-button>
</div>
</template> </template>
</el-dialog> </el-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus"; import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { ArrowLeft, Upload, Download, Share } from "@element-plus/icons-vue"; import { Edit, Upload, Download, Share } from "@element-plus/icons-vue";
import { import {
createDistributions, createDistributions,
deleteDocumentVersion, deleteDocumentVersion,
@@ -233,6 +332,7 @@ import {
fetchDocumentDetail, fetchDocumentDetail,
listDistributions, listDistributions,
uploadDocumentVersion, uploadDocumentVersion,
updateDocument,
} from "../../api/documents"; } from "../../api/documents";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
@@ -241,14 +341,17 @@ import type { Distribution, DocumentDetail, DocumentVersion } from "../../types/
import type { Site, StudyMember } from "../../types/api"; import type { Site, StudyMember } from "../../types/api";
import type { UserInfo } from "../../types/api"; import type { UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display"; import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta"; import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta(); const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const { can } = usePermission();
const documentId = computed(() => route.params.id as string); const documentId = computed(() => route.params.id as string);
const loading = ref(false); const loading = ref(false);
@@ -284,6 +387,21 @@ const siteActiveMap = computed(() => {
}); });
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false); const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
const isAdmin = computed(() => auth.user?.is_admin); const isAdmin = computed(() => auth.user?.is_admin);
const canReadDocument = computed(() => can("documents.read"));
const canUpdateDocument = computed(() => !isReadOnly.value && can("documents.update"));
const canDeleteDocument = computed(() => !isReadOnly.value && can("documents.delete"));
const canReadMembers = computed(() => can("project.members.list"));
const docTypeOptions = Object.keys(TEXT.enums.documentType).map((value) => ({
value,
label: TEXT.enums.documentType[value as keyof typeof TEXT.enums.documentType],
}));
const scopeOptions = [
{ value: "GLOBAL", label: TEXT.enums.scopeType.GLOBAL },
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
];
const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name, disabled: !site.is_active }))
);
const memberUserMap = computed(() => const memberUserMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, member) => { members.value.reduce<Record<string, string>>((acc, member) => {
@@ -318,6 +436,13 @@ const displaySite = (siteId?: string | null) => {
return sites.value.find((s) => s.id === siteId)?.name || siteId; return sites.value.find((s) => s.id === siteId)?.name || siteId;
}; };
const syncBreadcrumbContext = () => {
study.setViewContext({
siteName: displaySite(detail.site_id),
pageTitle: detail.title || TEXT.modules.fileVersionManagement.title,
});
};
const currentEffectiveVersion = computed(() => { const currentEffectiveVersion = computed(() => {
if (!detail.current_effective_version_id) return null; if (!detail.current_effective_version_id) return null;
return detail.version_timeline?.find((item: any) => item.id === detail.current_effective_version_id) || null; return detail.version_timeline?.find((item: any) => item.id === detail.current_effective_version_id) || null;
@@ -328,6 +453,16 @@ const distributionVersionId = computed(() => {
return detail.version_timeline?.[0]?.id || null; return detail.version_timeline?.[0]?.id || null;
}); });
const editorVisible = ref(false);
const savingEditor = ref(false);
const editorFormRef = ref<FormInstance>();
const editorForm = reactive({
title: "",
scope_type: "GLOBAL",
site_id: "",
doc_type: "",
});
const uploadVisible = ref(false); const uploadVisible = ref(false);
const uploading = ref(false); const uploading = ref(false);
const uploadFormRef = ref<FormInstance>(); const uploadFormRef = ref<FormInstance>();
@@ -361,6 +496,24 @@ const uploadRules: FormRules = {
version_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }], version_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
}; };
const editorRules: FormRules = {
title: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
scope_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
site_id: [
{
validator: (_rule, value, callback) => {
if (editorForm.scope_type === "SITE" && !value) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
},
trigger: "change",
},
],
doc_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
};
const roleOptions = computed(() => { const roleOptions = computed(() => {
const fallbackRoles = ["CRA", "PM", "CTA", "PV", "QA"]; const fallbackRoles = ["CRA", "PM", "CTA", "PV", "QA"];
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean)); const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
@@ -393,14 +546,11 @@ const displayTarget = (row: Distribution) => {
return row.target_id; return row.target_id;
}; };
const canAction = (action: string) => {
if (isReadOnly.value && action !== "view") return false;
const list = detail.can_actions || [];
if (list.length === 0) return true;
return list.includes(action);
};
const loadMembers = async () => { const loadMembers = async () => {
if (!canReadMembers.value) {
members.value = [];
return;
}
if (!detail.trial_id) { if (!detail.trial_id) {
members.value = []; members.value = [];
return; return;
@@ -451,6 +601,7 @@ const loadDetail = async () => {
if (!sites.value.length) { if (!sites.value.length) {
await loadSites(); await loadSites();
} }
syncBreadcrumbContext();
if (activeTab.value === "distributions") { if (activeTab.value === "distributions") {
await loadDistributions(); await loadDistributions();
} }
@@ -483,7 +634,53 @@ const handleTabChange = async (name: string) => {
} }
}; };
const openEdit = () => {
if (!canUpdateDocument.value) {
ElMessage.warning("权限不足");
return;
}
Object.assign(editorForm, {
title: detail.title || "",
scope_type: detail.scope_type || "GLOBAL",
site_id: detail.site_id || "",
doc_type: detail.doc_type || "",
});
editorFormRef.value?.clearValidate();
editorVisible.value = true;
};
const submitEditor = async () => {
if (!canUpdateDocument.value) {
ElMessage.warning("权限不足");
return;
}
if (!editorFormRef.value) return;
await editorFormRef.value.validate(async (valid) => {
if (!valid || !documentId.value) return;
savingEditor.value = true;
try {
await updateDocument(documentId.value, {
title: editorForm.title.trim(),
scope_type: editorForm.scope_type,
site_id: editorForm.scope_type === "SITE" ? editorForm.site_id : null,
doc_type: editorForm.doc_type,
});
editorVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.common.messages.updateSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
savingEditor.value = false;
}
});
};
const openUpload = () => { const openUpload = () => {
if (!canUpdateDocument.value) {
ElMessage.warning("权限不足");
return;
}
uploadForm.version_no = ""; uploadForm.version_no = "";
uploadForm.version_date = ""; uploadForm.version_date = "";
uploadForm.change_summary = ""; uploadForm.change_summary = "";
@@ -500,6 +697,10 @@ const onFileChange = (event: Event) => {
}; };
const submitUpload = async () => { const submitUpload = async () => {
if (!canUpdateDocument.value) {
ElMessage.warning("权限不足");
return;
}
if (!uploadFormRef.value) return; if (!uploadFormRef.value) return;
await uploadFormRef.value.validate(async (valid) => { await uploadFormRef.value.validate(async (valid) => {
if (!valid || !documentId.value || !uploadFile.value) { if (!valid || !documentId.value || !uploadFile.value) {
@@ -564,6 +765,10 @@ const downloadVersion = async (version: DocumentVersion) => {
const confirmDeleteVersion = async (version: DocumentVersion) => { const confirmDeleteVersion = async (version: DocumentVersion) => {
if (!version?.id) return; if (!version?.id) return;
if (!canDeleteDocument.value) {
ElMessage.warning("权限不足");
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;
try { try {
@@ -576,6 +781,10 @@ const confirmDeleteVersion = async (version: DocumentVersion) => {
}; };
const openDistribute = () => { const openDistribute = () => {
if (!canUpdateDocument.value) {
ElMessage.warning("权限不足");
return;
}
distributeForm.target_type = "ALL"; distributeForm.target_type = "ALL";
distributeForm.role_ids = []; distributeForm.role_ids = [];
distributeForm.user_ids = []; distributeForm.user_ids = [];
@@ -591,6 +800,10 @@ const onTargetTypeChange = () => {
}; };
const submitDistribute = async () => { const submitDistribute = async () => {
if (!canUpdateDocument.value) {
ElMessage.warning("权限不足");
return;
}
if (!distributionVersionId.value) { if (!distributionVersionId.value) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective); ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective);
return; return;
@@ -639,14 +852,6 @@ const submitDistribute = async () => {
} }
}; };
const goBack = () => {
if (detail.trial_id) {
router.push(`/trial/${detail.trial_id}/documents`);
} else {
router.back();
}
};
const formatDate = (value?: string | null) => { const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback; if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0]; return value.replace("T", " ").replace("Z", "").split(".")[0];
@@ -741,6 +946,47 @@ watch(
border-style: dashed; border-style: dashed;
} }
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.target-toolbar { .target-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5,12 +5,27 @@ import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./DocumentList.vue"), "utf8"); const readSource = () => readFileSync(resolve(__dirname, "./DocumentList.vue"), "utf8");
describe("DocumentList permissions", () => { describe("DocumentList permissions", () => {
it("guards create and delete actions with document operation permissions", () => { it("guards create edit and delete actions with document operation permissions", () => {
const source = readSource(); const source = readSource();
expect(source).toContain('can("documents.create")'); expect(source).toContain('can("documents.create")');
expect(source).toContain('can("documents.update")');
expect(source).toContain('can("documents.delete")'); expect(source).toContain('can("documents.delete")');
expect(source).toContain(":disabled=\"!canCreate\""); expect(source).toContain('v-if="canCreate"');
expect(source).toContain("v-if=\"canDelete\""); expect(source).toContain('v-if="canUpdate"');
expect(source).toContain('v-if="canDelete"');
expect(source).toContain('@click.stop="openEdit(row)"');
expect(source).not.toContain(':disabled="!canCreate"');
});
it("uses a drawer for creating documents instead of a dialog", () => {
const source = readSource();
expect(source).toContain("<el-drawer");
expect(source).toContain('class="document-editor-drawer"');
expect(source).toContain('class="editor-header"');
expect(source).toContain('class="drawer-footer"');
expect(source).not.toContain("<el-dialog");
expect(source).not.toContain('append-to=".layout-main .content-wrapper"');
}); });
}); });
+191 -59
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" :disabled="!canCreate" @click="openCreate" class="header-action-btn"> <el-button v-if="canCreate" type="primary" @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>
@@ -73,8 +73,11 @@
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span> <span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="120" align="center"> <el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="140" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="canUpdate" link type="primary" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-if="canDelete" 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>
@@ -87,35 +90,70 @@
</section> </section>
</div> </div>
<el-dialog append-to=".layout-main .content-wrapper" v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog"> <el-drawer
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" class="ctms-form"> v-if="editorVisible"
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docNo" prop="doc_no"> v-model="editorVisible"
<el-input v-model="createForm.doc_no" placeholder="e.g. SOP-001" /> direction="rtl"
</el-form-item> size="620px"
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.title" prop="title"> :close-on-click-modal="true"
<el-input v-model="createForm.title" /> :show-close="false"
</el-form-item> destroy-on-close
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.scope" prop="scope_type"> class="document-editor-drawer"
<el-select v-model="createForm.scope_type" style="width: 100%"> >
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" /> <template #header>
</el-select> <div class="editor-header">
</el-form-item> <div class="editor-title">{{ editingDocumentId ? TEXT.common.actions.edit : TEXT.modules.fileVersionManagement.dialog.createTitle }}</div>
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id"> </div>
<el-select v-model="createForm.site_id" filterable style="width: 100%"> </template>
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" />
</el-select> <el-form ref="editorFormRef" :model="editorForm" :rules="editorRules" label-position="top" class="document-form">
</el-form-item> <div class="form-group">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type"> <div class="form-group-title">
<el-select v-model="createForm.doc_type" style="width: 100%"> <span class="group-dot group-dot-basic"></span>
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" /> {{ TEXT.common.labels.basicInfo }}
</el-select> </div>
</el-form-item> <el-row :gutter="16">
<el-col v-if="!editingDocumentId" :span="12">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docNo" prop="doc_no">
<el-input v-model="editorForm.doc_no" placeholder="e.g. SOP-001" />
</el-form-item>
</el-col>
<el-col :span="editingDocumentId ? 24 : 12">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.title" prop="title">
<el-input v-model="editorForm.title" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.scope" prop="scope_type">
<el-select v-model="editorForm.scope_type" class="full-width">
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
<el-select v-model="editorForm.doc_type" class="full-width">
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="editorForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
<el-select v-model="editorForm.site_id" filterable class="full-width">
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" />
</el-select>
</el-form-item>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createVisible = false">{{ TEXT.common.actions.cancel }}</el-button> <div class="drawer-footer">
<el-button type="primary" :loading="creating" @click="submitCreate">{{ TEXT.common.actions.confirm }}</el-button> <el-button @click="editorVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="submitEditor">{{ TEXT.common.actions.confirm }}</el-button>
</div>
</template> </template>
</el-dialog> </el-drawer>
</div> </div>
</template> </template>
@@ -124,7 +162,7 @@ import { onMounted, reactive, ref, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus"; import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue"; import { Plus } from "@element-plus/icons-vue";
import { fetchDocuments, createDocument, deleteDocument } from "../../api/documents"; import { fetchDocuments, createDocument, deleteDocument, updateDocument } from "../../api/documents";
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";
@@ -142,9 +180,10 @@ 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[]>([]);
const createVisible = ref(false); const editorVisible = ref(false);
const creating = ref(false); const saving = ref(false);
const createFormRef = ref<FormInstance>(); const editingDocumentId = ref("");
const editorFormRef = ref<FormInstance>();
const trialId = computed(() => (route.params.trialId as string) || ""); const trialId = computed(() => (route.params.trialId as string) || "");
@@ -176,6 +215,7 @@ const isAdmin = computed(() => {
return !!auth.user?.is_admin; return !!auth.user?.is_admin;
}); });
const canCreate = computed(() => can("documents.create")); const canCreate = computed(() => can("documents.create"));
const canUpdate = computed(() => can("documents.update"));
const canDelete = computed(() => can("documents.delete")); 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 }) =>
@@ -192,7 +232,7 @@ const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id))) [...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
); );
const createForm = reactive({ const editorForm = reactive({
doc_no: "", doc_no: "",
title: "", title: "",
scope_type: "GLOBAL", scope_type: "GLOBAL",
@@ -200,14 +240,14 @@ const createForm = reactive({
doc_type: "", doc_type: "",
}); });
const createRules: FormRules = { const editorRules: FormRules = {
doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }], doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
title: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }], title: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
scope_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }], scope_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
site_id: [ site_id: [
{ {
validator: (_rule, value, callback) => { validator: (_rule, value, callback) => {
if (createForm.scope_type === "SITE" && !value) { if (editorForm.scope_type === "SITE" && !value) {
callback(new Error(TEXT.common.messages.required)); callback(new Error(TEXT.common.messages.required));
return; return;
} }
@@ -262,49 +302,88 @@ const resetFilters = () => {
filters.doc_type = ""; filters.doc_type = "";
}; };
const resetEditorForm = () => {
editorForm.doc_no = "";
editorForm.title = "";
editorForm.scope_type = "GLOBAL";
editorForm.site_id = "";
editorForm.doc_type = "";
editorFormRef.value?.clearValidate();
};
const openCreate = () => { const openCreate = () => {
if (!canCreate.value) { if (!canCreate.value) {
ElMessage.warning("权限不足"); ElMessage.warning("权限不足");
return; return;
} }
createForm.doc_no = ""; editingDocumentId.value = "";
createForm.title = ""; resetEditorForm();
createForm.scope_type = "GLOBAL"; editorVisible.value = true;
createForm.site_id = "";
createForm.doc_type = "";
createVisible.value = true;
}; };
const submitCreate = async () => { const openEdit = (row: DocumentSummary) => {
if (!canCreate.value) { if (!canUpdate.value) {
ElMessage.warning("权限不足"); ElMessage.warning("权限不足");
return; return;
} }
if (!createFormRef.value) return; if (isInactiveSite(row.site_id)) {
await createFormRef.value.validate(async (valid) => { ElMessage.warning("中心已停用");
return;
}
editingDocumentId.value = row.id;
Object.assign(editorForm, {
doc_no: "",
title: row.title || "",
scope_type: row.scope_type || "GLOBAL",
site_id: row.site_id || "",
doc_type: row.doc_type || "",
});
editorFormRef.value?.clearValidate();
editorVisible.value = true;
};
const submitEditor = async () => {
if (editingDocumentId.value && !canUpdate.value) {
ElMessage.warning("权限不足");
return;
}
if (!editingDocumentId.value && !canCreate.value) {
ElMessage.warning("权限不足");
return;
}
if (!editorFormRef.value) return;
await editorFormRef.value.validate(async (valid) => {
if (!valid || !trialId.value) return; if (!valid || !trialId.value) return;
creating.value = true; saving.value = true;
try { try {
if (createForm.scope_type === "SITE" && isInactiveSite(createForm.site_id)) { if (editorForm.scope_type === "SITE" && isInactiveSite(editorForm.site_id)) {
ElMessage.warning("中心已停用"); ElMessage.warning("中心已停用");
creating.value = false; saving.value = false;
return; return;
} }
await createDocument({ const payload = {
trial_id: trialId.value, site_id: editorForm.scope_type === "SITE" ? editorForm.site_id : null,
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined, title: editorForm.title.trim(),
doc_no: createForm.doc_no, doc_type: editorForm.doc_type,
title: createForm.title, scope_type: editorForm.scope_type,
doc_type: createForm.doc_type, };
scope_type: createForm.scope_type, if (editingDocumentId.value) {
}); await updateDocument(editingDocumentId.value, payload);
createVisible.value = false; } else {
await createDocument({
...payload,
trial_id: trialId.value,
doc_no: editorForm.doc_no.trim(),
site_id: editorForm.scope_type === "SITE" ? editorForm.site_id : undefined,
});
}
editorVisible.value = false;
await load(); await load();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.createSuccess); ElMessage.success(editingDocumentId.value ? TEXT.common.messages.updateSuccess : TEXT.modules.fileVersionManagement.messages.createSuccess);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally { } finally {
creating.value = false; saving.value = false;
} }
}); });
}; };
@@ -322,6 +401,10 @@ const confirmDelete = async (row: DocumentSummary) => {
return; return;
} }
if (!row?.id) return; if (!row?.id) return;
if (isInactiveSite(row.site_id)) {
ElMessage.warning("中心已停用");
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;
try { try {
@@ -392,6 +475,55 @@ watch(
border-radius: 10px; border-radius: 10px;
} }
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.document-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.full-width {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style> </style>
<style> <style>
@@ -0,0 +1,21 @@
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 project permissions", () => {
it("hides document create/update/delete controls and guards direct mutation calls by backend permissions", () => {
const source = readSource();
expect(source).toContain('v-if="canCreate"');
expect(source).toContain('v-if="canUpdate"');
expect(source).toContain('v-if="canDelete"');
expect(source).toContain("if (!canCreate.value)");
expect(source).toContain("if (!canUpdate.value)");
expect(source).toContain("if (!canDelete.value)");
expect(source).toContain("if (isInactiveSite(row.site_id))");
expect(source).toContain('@click.stop="openEdit(row)"');
expect(source).not.toContain(':disabled="!canCreate"');
});
});
+6 -11
View File
@@ -4,18 +4,13 @@ import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8"); const readSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
describe("EtmfPlaceholder", () => { describe("EtmfPlaceholder project permissions", () => {
it("renders a functional eTMF workspace instead of the generic placeholder", () => { it("hides eTMF create controls and guards dialog submissions with document create permission", () => {
const source = readSource(); const source = readSource();
expect(source).toContain("fetchEtmfTree"); expect(source).toContain('v-if="canCreate"');
expect(source).toContain("fetchEtmfNodeDocuments"); expect(source).toContain('v-if="canCreate && selectedNode"');
expect(source).toContain("createEtmfDocument"); expect(source).toContain("if (!canCreate.value)");
expect(source).toContain("etmf-tree-panel"); expect(source).not.toContain(':disabled="!canCreate"');
expect(source).toContain("etmf-document-table");
expect(source).toContain("etmf-node-detail");
expect(source).toContain("selectedNode");
expect(source).toContain("statusType");
expect(source).not.toContain("ModulePlaceholder");
}); });
}); });
+18 -2
View File
@@ -26,11 +26,11 @@
<div class="etmf-toolbar-actions"> <div class="etmf-toolbar-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button> <el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button :disabled="!canCreate" @click="openNodeDialog"> <el-button v-if="canCreate" @click="openNodeDialog">
<el-icon class="el-icon--left"><FolderAdd /></el-icon> <el-icon class="el-icon--left"><FolderAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newNode }} {{ TEXT.modules.etmf.actions.newNode }}
</el-button> </el-button>
<el-button type="primary" :disabled="!canCreate || !selectedNode" @click="openDocumentDialog"> <el-button v-if="canCreate && selectedNode" type="primary" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon> <el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }} {{ TEXT.modules.etmf.actions.newDocument }}
</el-button> </el-button>
@@ -418,6 +418,10 @@ const resetFilters = () => {
}; };
const openNodeDialog = () => { const openNodeDialog = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
nodeForm.code = ""; nodeForm.code = "";
nodeForm.name = ""; nodeForm.name = "";
nodeForm.scope_type = selectedNode.value?.scope_type || "GLOBAL"; nodeForm.scope_type = selectedNode.value?.scope_type || "GLOBAL";
@@ -426,6 +430,10 @@ const openNodeDialog = () => {
}; };
const submitNode = async () => { const submitNode = async () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId.value || !nodeForm.code.trim() || !nodeForm.name.trim()) { if (!studyId.value || !nodeForm.code.trim() || !nodeForm.name.trim()) {
ElMessage.warning(TEXT.common.messages.required); ElMessage.warning(TEXT.common.messages.required);
return; return;
@@ -451,6 +459,10 @@ const submitNode = async () => {
}; };
const openDocumentDialog = () => { const openDocumentDialog = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
if (!selectedNode.value) return; if (!selectedNode.value) return;
documentForm.doc_no = ""; documentForm.doc_no = "";
documentForm.title = ""; documentForm.title = "";
@@ -460,6 +472,10 @@ const openDocumentDialog = () => {
}; };
const submitDocument = async () => { const submitDocument = async () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId.value || !selectedNode.value || !documentForm.doc_no.trim() || !documentForm.title.trim() || !documentForm.doc_type) { if (!studyId.value || !selectedNode.value || !documentForm.doc_no.trim() || !documentForm.title.trim() || !documentForm.doc_type) {
ElMessage.warning(TEXT.common.messages.required); ElMessage.warning(TEXT.common.messages.required);
return; return;
@@ -80,4 +80,48 @@ describe("route view overlays are mounted only when visible", () => {
} }
} }
}); });
it("allows mask close on drawers while guarding dirty editor forms", () => {
const checks = [
{
file: "./ia/DrugShipments.vue",
snippets: [':close-on-click-modal="true"', ':before-close="drawerDirtyGuard.beforeClose"'],
},
{
file: "./ia/MaterialEquipment.vue",
snippets: [':close-on-click-modal="true"', ':before-close="drawerDirtyGuard.beforeClose"'],
},
{
file: "./ia/ProjectMilestones.vue",
snippets: [':close-on-click-modal="true"', ':before-close="editorDirtyGuard.beforeClose"'],
},
{
file: "./ia/RiskIssueMonitoringVisits.vue",
snippets: [':close-on-click-modal="true"', ':before-close="formDirtyGuard.beforeClose"'],
},
{
file: "./admin/ProjectDetail.vue",
snippets: [
':before-close="projectMilestoneEditorDirtyGuard.beforeClose"',
':before-close="siteMilestoneEditorDirtyGuard.beforeClose"',
':before-close="siteEnrollmentEditorDirtyGuard.beforeClose"',
],
},
{
file: "./admin/PermissionManagement.vue",
snippets: [':close-on-click-modal="true"', ':before-close="handleTemplateDrawerBeforeClose"'],
},
{
file: "./admin/AuditLogs.vue",
snippets: [':close-on-click-modal="true"'],
},
];
for (const check of checks) {
const source = normalizeWhitespace(readView(check.file));
for (const snippet of check.snippets) {
expect(source, `${check.file} should include ${snippet}`).toContain(normalizeWhitespace(snippet));
}
}
});
}); });