优化文档管理与导航体验
1、为文档增加编辑接口和前端编辑抽屉,文档列表与详情页统一按创建、更新、删除权限展示操作。 2、将文档版本上传和分发改为抽屉交互,并在详情页补充版本删除权限检查。 3、增强全局面包屑上下文和详情页标题同步,避免详情页重复或缺失导航信息。 4、按项目权限过滤快捷入口、首页费用指标和 eTMF 新建入口,并补充对应前端测试。
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"> </div>
|
<div class="ctms-section-title"> </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"> </div>
|
<div class="ctms-section-title"> </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"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user