新增电子试验主文件目录与文件归档

This commit is contained in:
Cheng Zhou
2026-05-28 10:54:13 +08:00
parent 31fcb7e6f2
commit fa961f1391
20 changed files with 2226 additions and 10 deletions
@@ -0,0 +1,88 @@
"""add etmf nodes
Revision ID: 20260527_09
Revises: 20260527_08_precautions
Create Date: 2026-05-27 20:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260527_09"
down_revision: Union[str, None] = "20260527_08_precautions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
uuid_type = postgresql.UUID(as_uuid=True)
scope_type = postgresql.ENUM(
"GLOBAL",
"SITE",
"DERIVED",
name="document_scope_type",
create_type=False,
)
if not _table_exists(inspector, "etmf_nodes"):
op.create_table(
"etmf_nodes",
sa.Column("id", uuid_type, nullable=False),
sa.Column("study_id", uuid_type, nullable=False),
sa.Column("parent_id", uuid_type, nullable=True),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope_type", scope_type, server_default="GLOBAL", nullable=False),
sa.Column("required", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("expected_doc_type", sa.String(length=50), nullable=True),
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["parent_id"], ["etmf_nodes.id"]),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"),
)
op.create_index("ix_etmf_nodes_study_id", "etmf_nodes", ["study_id"])
op.create_index("ix_etmf_nodes_parent_id", "etmf_nodes", ["parent_id"])
op.create_index("ix_etmf_nodes_scope_type", "etmf_nodes", ["scope_type"])
op.create_index("ix_etmf_nodes_is_active", "etmf_nodes", ["is_active"])
if _table_exists(inspector, "documents") and not _column_exists(inspector, "documents", "etmf_node_id"):
op.add_column("documents", sa.Column("etmf_node_id", uuid_type, nullable=True))
op.create_index("ix_documents_etmf_node_id", "documents", ["etmf_node_id"])
op.create_foreign_key("fk_documents_etmf_node_id", "documents", "etmf_nodes", ["etmf_node_id"], ["id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "documents") and _column_exists(inspector, "documents", "etmf_node_id"):
op.drop_constraint("fk_documents_etmf_node_id", "documents", type_="foreignkey")
op.drop_index("ix_documents_etmf_node_id", table_name="documents")
op.drop_column("documents", "etmf_node_id")
if _table_exists(inspector, "etmf_nodes"):
op.drop_index("ix_etmf_nodes_is_active", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_scope_type", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_parent_id", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_study_id", table_name="etmf_nodes")
op.drop_table("etmf_nodes")
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.schemas.document import DocumentCreate, DocumentSummary
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeUpdate, EtmfTreeNode
from app.services import etmf_service
router = APIRouter()
@router.get("/tree", response_model=list[EtmfTreeNode])
async def get_etmf_tree(
study_id: uuid.UUID = Query(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[EtmfTreeNode]:
return await etmf_service.list_etmf_tree(db, study_id=study_id, current_user=current_user)
@router.post("/nodes", response_model=EtmfNodeRead, status_code=status.HTTP_201_CREATED)
async def create_etmf_node(
payload: EtmfNodeCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> EtmfNodeRead:
node = await etmf_service.create_node(db, payload, current_user)
return EtmfNodeRead.model_validate(node)
@router.patch("/nodes/{node_id}", response_model=EtmfNodeRead)
async def update_etmf_node(
node_id: uuid.UUID,
payload: EtmfNodeUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> EtmfNodeRead:
node = await etmf_service.update_node(db, node_id, payload, current_user)
return EtmfNodeRead.model_validate(node)
@router.get("/nodes/{node_id}/documents", response_model=list[DocumentSummary])
async def list_etmf_node_documents(
node_id: uuid.UUID,
site_id: uuid.UUID | None = Query(None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[DocumentSummary]:
return await etmf_service.list_node_documents(db, node_id=node_id, site_id=site_id, current_user=current_user)
@router.post("/nodes/{node_id}/documents", response_model=DocumentSummary, status_code=status.HTTP_201_CREATED)
async def create_etmf_node_document(
node_id: uuid.UUID,
payload: DocumentCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
return await etmf_service.create_node_document(db, node_id=node_id, payload=payload, current_user=current_user)
+5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid
from typing import Sequence
@@ -33,6 +35,7 @@ async def list_documents(
doc_type: str | None = None,
status: str | None = None,
scope_type: str | None = None,
etmf_node_id: uuid.UUID | None = None,
cra_site_ids: set[uuid.UUID] | None = None,
skip: int = 0,
limit: int = 100,
@@ -51,6 +54,8 @@ async def list_documents(
stmt = stmt.where(Document.status == status)
if site_id:
stmt = stmt.where(Document.site_id == site_id)
if etmf_node_id:
stmt = stmt.where(Document.etmf_node_id == etmf_node_id)
stmt = stmt.order_by(Document.updated_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.etmf import EtmfNode
async def get_node(db: AsyncSession, node_id: uuid.UUID) -> EtmfNode | None:
result = await db.execute(select(EtmfNode).where(EtmfNode.id == node_id))
return result.scalar_one_or_none()
async def list_nodes_by_study(db: AsyncSession, study_id: uuid.UUID, *, include_inactive: bool = True) -> Sequence[EtmfNode]:
stmt = select(EtmfNode).where(EtmfNode.study_id == study_id)
if not include_inactive:
stmt = stmt.where(EtmfNode.is_active.is_(True))
stmt = stmt.order_by(EtmfNode.sort_order.asc(), EtmfNode.code.asc(), EtmfNode.name.asc())
result = await db.execute(stmt)
return result.scalars().all()
async def create_node(db: AsyncSession, node: EtmfNode, *, commit: bool = True) -> EtmfNode:
db.add(node)
if commit:
await db.commit()
await db.refresh(node)
return node
async def update_node(db: AsyncSession, node_id: uuid.UUID, values: dict, *, commit: bool = True) -> None:
if values:
await db.execute(sa_update(EtmfNode).where(EtmfNode.id == node_id).values(**values))
if commit:
await db.commit()
+1
View File
@@ -31,6 +31,7 @@ class Document(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
trial_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
etmf_node_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("etmf_nodes.id"), nullable=True)
doc_no: Mapped[str] = mapped_column(String(50), nullable=False)
doc_type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
from app.models.document import DocumentScopeType
class EtmfNode(Base):
__tablename__ = "etmf_nodes"
__table_args__ = (
UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("etmf_nodes.id"), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
scope_type: Mapped[DocumentScopeType] = mapped_column(
Enum(DocumentScopeType, name="document_scope_type"),
nullable=False,
server_default=DocumentScopeType.GLOBAL.value,
)
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
expected_doc_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
parent = relationship("EtmfNode", remote_side=[id], foreign_keys=[parent_id])
+3
View File
@@ -25,6 +25,7 @@ class DocumentStatus(str, enum.Enum):
class DocumentCreate(BaseModel):
trial_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
etmf_node_id: Optional[uuid.UUID] = None
doc_no: str
doc_type: str
title: str
@@ -39,6 +40,7 @@ class DocumentUpdate(BaseModel):
scope_type: Optional[DocumentScopeType] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
etmf_node_id: Optional[uuid.UUID] = None
status: Optional[DocumentStatus] = None
description: Optional[str] = None
@@ -47,6 +49,7 @@ class DocumentSummary(BaseModel):
id: uuid.UUID
trial_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
etmf_node_id: Optional[uuid.UUID] = None
doc_type: str
title: str
scope_type: DocumentScopeType
+72
View File
@@ -0,0 +1,72 @@
import enum
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.document import DocumentScopeType, DocumentSummary
class EtmfNodeStatus(str, enum.Enum):
NOT_REQUIRED = "NOT_REQUIRED"
MISSING = "MISSING"
UPLOADED = "UPLOADED"
EFFECTIVE = "EFFECTIVE"
INACTIVE = "INACTIVE"
class EtmfNodeBase(BaseModel):
parent_id: Optional[uuid.UUID] = None
code: str
name: str
description: Optional[str] = None
scope_type: DocumentScopeType = DocumentScopeType.GLOBAL
required: bool = False
expected_doc_type: Optional[str] = None
sort_order: int = 0
is_active: bool = True
class EtmfNodeCreate(EtmfNodeBase):
study_id: uuid.UUID
class EtmfNodeUpdate(BaseModel):
parent_id: Optional[uuid.UUID] = None
code: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
scope_type: Optional[DocumentScopeType] = None
required: Optional[bool] = None
expected_doc_type: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class EtmfNodeRead(EtmfNodeBase):
id: uuid.UUID
study_id: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class EtmfNodeStatusSummary(BaseModel):
status: EtmfNodeStatus
document_count: int = 0
effective_document_count: int = 0
missing_count: int = 0
class EtmfTreeNode(EtmfNodeRead):
status: EtmfNodeStatus = EtmfNodeStatus.NOT_REQUIRED
document_count: int = 0
effective_document_count: int = 0
children: list["EtmfTreeNode"] = Field(default_factory=list)
class EtmfNodeDocuments(BaseModel):
node: EtmfNodeRead
documents: list[DocumentSummary] = Field(default_factory=list)
+23 -3
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import hashlib
import json
import uuid
@@ -16,6 +18,7 @@ from app.core.project_permissions import role_has_api_permission
from app.crud import acknowledgement as acknowledgement_crud
from app.crud import distribution as distribution_crud
from app.crud import document as document_crud
from app.crud import etmf as etmf_crud
from app.crud import document_version as version_crud
from app.crud import member as member_crud
from app.crud import site as site_crud
@@ -58,6 +61,7 @@ def _doc_snapshot(doc: Document) -> dict:
"id": str(doc.id),
"trial_id": str(doc.trial_id),
"site_id": str(doc.site_id) if doc.site_id else None,
"etmf_node_id": str(doc.etmf_node_id) if doc.etmf_node_id else None,
"doc_no": doc.doc_no,
"status": str(doc.status),
"current_effective_version_id": str(doc.current_effective_version_id) if doc.current_effective_version_id else None,
@@ -108,9 +112,20 @@ async def create_document(
current_user,
) -> Document:
await _ensure_study_access(db, doc_in.trial_id, current_user, action="create_document")
if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id:
etmf_node = None
if doc_in.etmf_node_id:
etmf_node = await etmf_crud.get_node(db, doc_in.etmf_node_id)
if not etmf_node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
if etmf_node.study_id != doc_in.trial_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="eTMF目录不属于当前项目")
if etmf_node.scope_type == DocumentScopeType.SITE and not doc_in.site_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="中心级eTMF目录需指定中心")
scope_type = etmf_node.scope_type if etmf_node else doc_in.scope_type
if scope_type == DocumentScopeType.SITE and not doc_in.site_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心")
site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None
site_id = doc_in.site_id if scope_type == DocumentScopeType.SITE else None
if site_id:
site = await site_crud.get_site(db, site_id)
if not site or not site.is_active:
@@ -122,10 +137,11 @@ async def create_document(
doc = Document(
trial_id=doc_in.trial_id,
site_id=site_id,
etmf_node_id=doc_in.etmf_node_id,
doc_no=doc_in.doc_no,
doc_type=doc_in.doc_type,
title=doc_in.title,
scope_type=doc_in.scope_type,
scope_type=scope_type,
owner_id=doc_in.owner_id,
description=doc_in.description,
)
@@ -154,6 +170,7 @@ async def list_documents(
doc_type: str | None,
status: str | None,
scope_type: str | None,
etmf_node_id: uuid.UUID | None = None,
skip: int,
limit: int,
current_user,
@@ -168,6 +185,7 @@ async def list_documents(
doc_type=doc_type,
status=status,
scope_type=scope_type,
etmf_node_id=etmf_node_id,
cra_site_ids=cra_site_ids,
skip=skip,
limit=limit,
@@ -185,6 +203,7 @@ async def list_documents(
id=doc.id,
trial_id=doc.trial_id,
site_id=doc.site_id,
etmf_node_id=doc.etmf_node_id,
doc_type=doc.doc_type,
title=doc.title,
scope_type=doc.scope_type,
@@ -228,6 +247,7 @@ async def get_document_detail(
id=doc.id,
trial_id=doc.trial_id,
site_id=doc.site_id,
etmf_node_id=doc.etmf_node_id,
doc_type=doc.doc_type,
title=doc.title,
scope_type=doc.scope_type,
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import uuid
from collections import defaultdict
from typing import Iterable
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import document as document_crud
from app.crud import etmf as etmf_crud
from app.models.document import Document, DocumentStatus
from app.models.etmf import EtmfNode
from app.models.audit_log import AuditLog
from app.schemas.document import DocumentCreate, DocumentSummary
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate
def _role_value(user) -> str:
if user is None:
return "SYSTEM"
return user.role.value if hasattr(user.role, "value") else str(user.role)
def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus:
docs = list(documents)
if not node.is_active:
return EtmfNodeStatus.INACTIVE
if any(doc.current_effective_version_id for doc in docs):
return EtmfNodeStatus.EFFECTIVE
if docs:
return EtmfNodeStatus.UPLOADED
if node.required:
return EtmfNodeStatus.MISSING
return EtmfNodeStatus.NOT_REQUIRED
def _tree_node(node: EtmfNode, documents: list[Document], children: list[EtmfTreeNode]) -> EtmfTreeNode:
return EtmfTreeNode(
id=node.id,
study_id=node.study_id,
parent_id=node.parent_id,
code=node.code,
name=node.name,
description=node.description,
scope_type=node.scope_type,
required=node.required,
expected_doc_type=node.expected_doc_type,
sort_order=node.sort_order,
is_active=node.is_active,
created_at=node.created_at,
updated_at=node.updated_at,
status=calculate_node_status(node, documents),
document_count=len(documents),
effective_document_count=sum(1 for doc in documents if doc.current_effective_version_id),
children=children,
)
async def _documents_by_node(db: AsyncSession, study_id: uuid.UUID) -> dict[uuid.UUID, list[Document]]:
result = await db.execute(
select(Document).where(
Document.trial_id == study_id,
Document.etmf_node_id.is_not(None),
Document.status != DocumentStatus.ARCHIVED,
)
)
grouped: dict[uuid.UUID, list[Document]] = defaultdict(list)
for document in result.scalars().all():
if document.etmf_node_id:
grouped[document.etmf_node_id].append(document)
return grouped
async def list_etmf_tree(db: AsyncSession, *, study_id: uuid.UUID, current_user) -> list[EtmfTreeNode]:
if current_user is not None:
from app.services import document_service
await document_service._ensure_study_access(db, study_id, current_user, action="view")
nodes = list(await etmf_crud.list_nodes_by_study(db, study_id))
documents = await _documents_by_node(db, study_id)
children_by_parent: dict[uuid.UUID | None, list[EtmfNode]] = defaultdict(list)
for node in nodes:
children_by_parent[node.parent_id].append(node)
def build(node: EtmfNode) -> EtmfTreeNode:
children = [build(child) for child in children_by_parent.get(node.id, [])]
return _tree_node(node, documents.get(node.id, []), children)
return [build(node) for node in children_by_parent.get(None, [])]
async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) -> EtmfNode:
from app.services import document_service
await document_service._ensure_study_access(db, payload.study_id, current_user, action="create_document")
if payload.parent_id:
parent = await etmf_crud.get_node(db, payload.parent_id)
if not parent or parent.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不存在或不属于当前项目")
node = EtmfNode(**payload.model_dump())
db.add(node)
db.add(
AuditLog(
study_id=payload.study_id,
entity_type="ETMF_NODE",
entity_id=node.id,
action="ETMF_NODE_CREATED",
detail=f'{{"code":"{node.code}","name":"{node.name}"}}',
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(node)
return node
async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpdate, current_user) -> EtmfNode:
from app.services import document_service
node = await etmf_crud.get_node(db, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
await document_service._ensure_study_access(db, node.study_id, current_user, action="create_version")
values = payload.model_dump(exclude_unset=True)
if "parent_id" in values and values["parent_id"]:
parent = await etmf_crud.get_node(db, values["parent_id"])
if not parent or parent.study_id != node.study_id or parent.id == node.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不合法")
await etmf_crud.update_node(db, node_id, values, commit=False)
db.add(
AuditLog(
study_id=node.study_id,
entity_type="ETMF_NODE",
entity_id=node.id,
action="ETMF_NODE_UPDATED",
detail="{}",
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
refreshed = await etmf_crud.get_node(db, node_id)
if not refreshed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
return refreshed
async def list_node_documents(
db: AsyncSession,
*,
node_id: uuid.UUID,
site_id: uuid.UUID | None,
current_user,
) -> list[DocumentSummary]:
from app.services import document_service
node = await etmf_crud.get_node(db, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
return await document_service.list_documents(
db,
trial_id=node.study_id,
site_id=site_id,
doc_type=None,
status=None,
scope_type=None,
etmf_node_id=node_id,
skip=0,
limit=500,
current_user=current_user,
)
async def create_node_document(
db: AsyncSession,
*,
node_id: uuid.UUID,
payload: DocumentCreate,
current_user,
) -> DocumentSummary:
from app.services import document_service
node = await etmf_crud.get_node(db, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
doc_payload = payload.model_copy(update={"trial_id": node.study_id, "etmf_node_id": node.id, "scope_type": node.scope_type})
document = await document_service.create_document(db, doc_payload, current_user)
return DocumentSummary.model_validate(document)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import uuid
from types import SimpleNamespace
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.document import Document, DocumentScopeType
from app.models.document_version import DocumentVersion # noqa: F401
from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.etmf import EtmfNode
from app.services.etmf_service import calculate_node_status, list_etmf_tree
def test_required_node_without_documents_is_missing() -> None:
node = SimpleNamespace(is_active=True, required=True)
assert calculate_node_status(node, []) == "MISSING"
def test_node_with_document_without_effective_version_is_uploaded() -> None:
node = SimpleNamespace(is_active=True, required=True)
document = SimpleNamespace(current_effective_version_id=None)
assert calculate_node_status(node, [document]) == "UPLOADED"
def test_node_with_current_effective_version_is_effective() -> None:
node = SimpleNamespace(is_active=True, required=True)
document = SimpleNamespace(current_effective_version_id=uuid.uuid4())
assert calculate_node_status(node, [document]) == "EFFECTIVE"
@pytest.mark.asyncio
async def test_etmf_tree_returns_hierarchy_and_status(db_session: AsyncSession) -> None:
study_id = uuid.uuid4()
parent = EtmfNode(
id=uuid.uuid4(),
study_id=study_id,
code="01",
name="项目管理",
scope_type=DocumentScopeType.GLOBAL,
required=False,
sort_order=1,
)
child = EtmfNode(
id=uuid.uuid4(),
study_id=study_id,
parent_id=parent.id,
code="01.01",
name="方案与版本",
scope_type=DocumentScopeType.GLOBAL,
required=True,
sort_order=1,
)
db_session.add_all([parent, child])
await db_session.commit()
tree = await list_etmf_tree(db_session, study_id=study_id, current_user=None)
assert len(tree) == 1
assert tree[0].code == "01"
assert tree[0].children[0].code == "01.01"
assert tree[0].children[0].status == "MISSING"
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import uuid
import pytest
from fastapi import HTTPException
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base_class import Base
from app.models.document import Document, DocumentScopeType
from app.models.document_version import DocumentVersion # noqa: F401
from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.user import UserRole, UserStatus
class UserStub:
def __init__(self) -> None:
self.id = uuid.uuid4()
self.role = UserRole.ADMIN
self.status = UserStatus.ACTIVE
def test_etmf_node_table_is_registered() -> None:
import app.db.base # noqa: F401
assert "etmf_nodes" in Base.metadata.tables
@pytest.mark.asyncio
async def test_document_create_persists_etmf_node_id(db_session: AsyncSession) -> None:
from app.models.etmf import EtmfNode
from app.schemas.document import DocumentCreate
from app.services import document_service
study_id = (
await db_session.execute(text("SELECT id FROM studies ORDER BY created_at DESC LIMIT 1"))
).scalar_one()
node = EtmfNode(
study_id=study_id,
code="01.01",
name="方案与版本",
scope_type=DocumentScopeType.GLOBAL,
required=True,
sort_order=1,
)
db_session.add(node)
await db_session.commit()
await db_session.refresh(node)
doc = await document_service.create_document(
db_session,
DocumentCreate(
trial_id=study_id,
doc_no="PROTOCOL-001",
doc_type="Protocol",
title="研究方案",
scope_type=DocumentScopeType.GLOBAL,
etmf_node_id=node.id,
),
UserStub(),
)
assert doc.etmf_node_id == node.id
stored = (
await db_session.execute(select(Document).where(Document.id == doc.id))
).scalar_one()
assert stored.etmf_node_id == node.id
@pytest.mark.asyncio
async def test_site_scoped_etmf_node_requires_site_document(db_session: AsyncSession) -> None:
from app.models.etmf import EtmfNode
from app.schemas.document import DocumentCreate
from app.services import document_service
study_id = (
await db_session.execute(text("SELECT id FROM studies ORDER BY created_at DESC LIMIT 1"))
).scalar_one()
node = EtmfNode(
study_id=study_id,
code="03.01",
name="中心启动文件",
scope_type=DocumentScopeType.SITE,
required=True,
sort_order=1,
)
db_session.add(node)
await db_session.commit()
await db_session.refresh(node)
with pytest.raises(HTTPException) as exc_info:
await document_service.create_document(
db_session,
DocumentCreate(
trial_id=study_id,
doc_no="SITE-STARTUP-001",
doc_type="Startup",
title="中心启动文件",
scope_type=DocumentScopeType.GLOBAL,
etmf_node_id=node.id,
),
UserStub(),
)
assert exc_info.value.status_code == 422
assert "中心" in str(exc_info.value.detail)
+151
View File
@@ -0,0 +1,151 @@
# eTMF Module Design
## 背景
当前系统已有文件版本管理能力,包括 `documents``document_versions`、文件上传下载、分发、回执、权限和审计。`/etmf` 路由目前是占位页,并已复用 `documents:read` 权限。
eTMF 第一阶段需要同时满足两个目标:
- 合规归档视图:按 TMF 标准目录检查项目级和中心级文件归档状态。
- 文件管理入口:项目成员可以按目录上传、查找、下载、维护版本。
## 推荐方案
采用“TMF 目录树模板 + 文档元数据挂载”模型。前端呈现为目录结构,但后端不把 eTMF 当作真实文件夹系统,而是用目录节点表达归档标准,用现有文档实体承载文件和版本。
该方案保留文件夹式使用体验,同时支持合规检查、缺失项统计、中心维度对比和后续报表扩展。
## 核心模型
### eTMF 目录节点
新增 `etmf_nodes` 表,表达项目内 eTMF 目录树。
建议字段:
- `id`
- `study_id`
- `parent_id`
- `code`
- `name`
- `description`
- `scope_type`: `GLOBAL``SITE`
- `required`
- `expected_doc_type`
- `sort_order`
- `is_active`
- `created_at`
- `updated_at`
约束:
- 同一项目、同一父节点下 `code` 唯一。
- 节点软停用,不物理删除,避免历史文档失去归档上下文。
- 第一阶段不做自定义模板版本管理,只提供项目内节点树。
### 文档挂载
复用现有 `documents` 表,新增 `etmf_node_id` 外键。
规则:
- `documents.etmf_node_id` 为空表示普通文件版本管理文档。
- `documents.etmf_node_id` 非空表示该文档归档到 eTMF 节点。
- 项目级节点对应 `documents.site_id = null`
- 中心级节点对应 `documents.site_id` 有值。
第一阶段不新增独立 eTMF 文件表,避免重复存储文件元数据和版本逻辑。
## 状态计算
eTMF 状态优先通过查询计算,不在第一阶段持久化冗余状态。
节点状态:
- `NOT_REQUIRED`: 非必需节点且无文档。
- `MISSING`: 必需节点没有匹配文档。
- `UPLOADED`: 有文档但没有生效版本。
- `EFFECTIVE`: 至少一个匹配文档存在当前生效版本。
- `INACTIVE`: 节点停用。
中心级节点按中心分别计算状态。项目级节点只计算项目维度。
## 后端接口
新增 eTMF API,挂载在现有 v1 router 下。
建议接口:
- `GET /api/v1/etmf/tree?study_id=...`
返回节点树、每个节点汇总状态和文档数量。
- `GET /api/v1/etmf/nodes/{node_id}/documents?site_id=...`
返回节点下文档列表,复用 `DocumentSummary`
- `POST /api/v1/etmf/nodes`
创建节点。
- `PATCH /api/v1/etmf/nodes/{node_id}`
更新节点名称、排序、必需性、适用范围。
- `POST /api/v1/etmf/nodes/{node_id}/documents`
在节点下创建文档,本质调用现有 `create_document`,自动带入 `etmf_node_id`
第一阶段不做节点移动和批量导入,除非后续确认需要。
## 前端体验
替换当前 `EtmfPlaceholder.vue`
页面结构:
- 顶部工具栏:中心筛选、状态筛选、刷新、新增目录、新增文档。
- 左侧:eTMF 目录树,显示节点名称、编码、状态标记、缺失提示。
- 中间:当前节点文档列表,展示标题、类型、范围、中心、当前版本、更新时间、操作。
- 右侧:节点详情,展示必需性、适用范围、完成状态、缺失说明、中心完成度摘要。
交互规则:
- 点击目录节点加载该节点文档。
- 创建文档时默认带入当前节点和当前中心筛选。
- 文档详情和版本维护复用现有文档详情页。
- 中心级节点在选择中心后展示该中心归档状态;未选中心时展示中心完成度汇总。
## 权限和审计
第一阶段复用现有文档权限:
- 查看:`documents:read`
- 新建文档或目录:`documents:create`
- 更新文档版本或节点:`documents:update`
- 删除或停用:`documents:delete`
审计:
- 文档创建、版本上传、下载、删除继续复用现有文档审计。
- 节点创建、更新、停用新增 `ETMF_NODE_CREATED``ETMF_NODE_UPDATED``ETMF_NODE_DISABLED` 审计动作。
## 测试策略
后端:
- 节点树接口按项目隔离。
- 必需节点缺失状态计算正确。
- 中心级节点按 `site_id` 计算状态。
- CRA 只能看到自身中心范围内的中心级文件。
- 节点下创建文档会写入 `etmf_node_id`
前端:
- `/etmf` 不再展示占位页。
- 节点树加载、节点选择、中心筛选、状态展示。
- 新建文档时携带当前节点。
- 路由权限继续使用 `documents:read`
## 取舍
第一阶段明确不做:
- 电子签名。
- 复杂审批流。
- 模板版本发布。
- 物理文件夹移动。
- 批量导入 TMF 标准目录。
这些能力可以在基础目录树和文档挂载稳定后追加。
@@ -0,0 +1,386 @@
# eTMF Module Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build an eTMF module that combines a compliant TMF directory tree with reusable document version management.
**Architecture:** Add eTMF directory nodes as metadata, then mount existing `documents` records onto those nodes with `etmf_node_id`. Keep file storage, versions, permissions, downloads, distribution, and audit behavior in the existing document module.
**Tech Stack:** FastAPI, SQLAlchemy async ORM, Alembic, PostgreSQL, Vue 3, Element Plus, Vitest, Pytest.
---
## Implementation Notes
The repository instruction says not to plan or execute git commits unless explicitly requested. This plan intentionally omits commit steps.
Follow TDD for each backend behavior before implementation. Keep the first release small: no e-signature, no workflow redesign, no template versioning, no bulk import.
### Task 1: Backend eTMF Node Model
**Files:**
- Create: `backend/app/models/etmf.py`
- Create: `backend/app/schemas/etmf.py`
- Modify: `backend/app/db/base.py`
- Create: `backend/alembic/versions/20260527_07_add_etmf_nodes.py`
- Test: `backend/tests/test_etmf_nodes.py`
**Step 1: Write failing model/import test**
Add a test that imports `EtmfNode` through `app.db.base` metadata and verifies the table name is registered.
Run:
```bash
cd backend
pytest tests/test_etmf_nodes.py -q
```
Expected: fail because `app.models.etmf` does not exist.
**Step 2: Add model**
Create `EtmfNode` with these columns:
- `id`
- `study_id`
- `parent_id`
- `code`
- `name`
- `description`
- `scope_type`
- `required`
- `expected_doc_type`
- `sort_order`
- `is_active`
- `created_at`
- `updated_at`
Use existing enum values from document scope where practical. Keep node-specific logic out of the model.
**Step 3: Add schemas**
Create request and response schemas:
- `EtmfNodeCreate`
- `EtmfNodeUpdate`
- `EtmfNodeRead`
- `EtmfTreeNode`
- `EtmfNodeStatusSummary`
**Step 4: Add migration**
Create `etmf_nodes` with:
- Foreign key to `studies.id`
- Self foreign key `parent_id`
- Unique constraint on `(study_id, parent_id, code)`
- Indexes on `study_id`, `parent_id`, `scope_type`, `is_active`
**Step 5: Verify**
Run:
```bash
cd backend
pytest tests/test_etmf_nodes.py -q
```
Expected: pass.
### Task 2: Link Documents To eTMF Nodes
**Files:**
- Modify: `backend/app/models/document.py`
- Modify: `backend/app/schemas/document.py`
- Modify: `backend/app/crud/document.py`
- Modify: `backend/app/services/document_service.py`
- Modify: `frontend/src/types/documents.ts`
- Modify: `backend/alembic/versions/20260527_07_add_etmf_nodes.py`
- Test: `backend/tests/test_etmf_nodes.py`
**Step 1: Write failing service test**
Test that creating a document with `etmf_node_id` persists the relationship and list/detail responses include it.
Expected: fail because schema and model do not include `etmf_node_id`.
**Step 2: Add model column**
Add nullable `Document.etmf_node_id` with foreign key to `etmf_nodes.id`.
**Step 3: Add schema fields**
Add `etmf_node_id` to:
- `DocumentCreate`
- `DocumentUpdate`
- `DocumentSummary`
- `DocumentDetail`
**Step 4: Validate node scope**
In `document_service.create_document`, if `etmf_node_id` is provided:
- Verify node exists.
- Verify node belongs to the same study.
- If node scope is `SITE`, require `site_id`.
- If node scope is `GLOBAL`, normalize `site_id` to null unless the product decision changes.
**Step 5: Verify**
Run:
```bash
cd backend
pytest tests/test_etmf_nodes.py -q
```
Expected: pass.
### Task 3: eTMF Service And Tree API
**Files:**
- Create: `backend/app/crud/etmf.py`
- Create: `backend/app/services/etmf_service.py`
- Create: `backend/app/api/v1/etmf.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_etmf_api.py`
**Step 1: Write failing API tests**
Cover:
- `GET /api/v1/etmf/tree?study_id=...`
- Required empty node returns `MISSING`.
- Node with document but no effective version returns `UPLOADED`.
- Node with current effective version returns `EFFECTIVE`.
- Site-scoped node can return per-site status.
Expected: fail because API does not exist.
**Step 2: Implement CRUD**
Add small CRUD functions:
- `get_node`
- `list_nodes_by_study`
- `create_node`
- `update_node`
Avoid broad generic repository abstractions.
**Step 3: Implement service**
Add tree assembly and status calculation:
- Build parent-child tree in memory.
- Query documents for target study and group by `etmf_node_id`.
- Calculate node status from required flag and document effective version presence.
- Keep status calculation deterministic and isolated for unit tests.
**Step 4: Implement routes**
Add:
- `GET /tree`
- `GET /nodes/{node_id}/documents`
- `POST /nodes`
- `PATCH /nodes/{node_id}`
- `POST /nodes/{node_id}/documents`
Use existing project permission checks through the document permission model.
**Step 5: Verify**
Run:
```bash
cd backend
pytest tests/test_etmf_api.py -q
```
Expected: pass.
### Task 4: Frontend API And Types
**Files:**
- Create: `frontend/src/types/etmf.ts`
- Create: `frontend/src/api/etmf.ts`
- Test: `frontend/src/api/etmf.test.ts`
**Step 1: Write failing API test**
Test that each frontend API helper calls the expected URL and HTTP method.
Expected: fail because `src/api/etmf.ts` does not exist.
**Step 2: Add types**
Add types matching backend schemas:
- `EtmfNodeStatus`
- `EtmfTreeNode`
- `EtmfNodeCreatePayload`
- `EtmfNodeUpdatePayload`
**Step 3: Add API helpers**
Add:
- `fetchEtmfTree`
- `fetchEtmfNodeDocuments`
- `createEtmfNode`
- `updateEtmfNode`
- `createEtmfDocument`
**Step 4: Verify**
Run:
```bash
cd frontend
npm test -- src/api/etmf.test.ts
```
Expected: pass.
### Task 5: eTMF Page
**Files:**
- Replace: `frontend/src/views/ia/EtmfPlaceholder.vue`
- Modify: `frontend/src/locales/zh-CN.ts`
- Test: `frontend/src/views/ia/EtmfPlaceholder.test.ts`
**Step 1: Write failing page test**
Test that `/etmf` page:
- Renders tree panel.
- Loads eTMF tree for current study.
- Selecting a node loads its documents.
- Shows missing/effective status labels.
Expected: fail because the page is still a placeholder.
**Step 2: Build layout**
Use existing CTMS shell classes:
- Top action bar.
- Left tree panel.
- Main document table.
- Right node detail panel.
Do not introduce a marketing-style page or nested card layout.
**Step 3: Add interactions**
Implement:
- Center filter.
- Node selection.
- Refresh.
- Create node dialog.
- Create document dialog that posts to eTMF node document API.
Reuse existing display helpers and document enums.
**Step 4: Verify**
Run:
```bash
cd frontend
npm test -- src/views/ia/EtmfPlaceholder.test.ts
```
Expected: pass.
### Task 6: Route And Permission Regression
**Files:**
- Modify: `frontend/src/utils/projectRoutePermissions.ts`
- Modify: `frontend/src/utils/projectRoutePermissions.test.ts`
- Modify: `backend/tests/test_api_permissions.py`
**Step 1: Write failing regression tests**
Ensure:
- `/etmf` still requires `documents:read`.
- eTMF write endpoints are covered by document permissions.
- Existing document permission tests remain stable.
**Step 2: Implement minimal permission wiring**
Keep first-stage eTMF permissions mapped to existing document operation keys.
**Step 3: Verify**
Run:
```bash
cd backend
pytest tests/test_api_permissions.py -q
cd ../frontend
npm test -- src/utils/projectRoutePermissions.test.ts
```
Expected: pass.
### Task 7: End-To-End Verification
**Files:**
- No source changes expected unless verification finds a bug.
**Step 1: Run backend focused tests**
```bash
cd backend
pytest tests/test_etmf_nodes.py tests/test_etmf_api.py tests/test_api_permissions.py -q
```
Expected: pass.
**Step 2: Run frontend focused tests**
```bash
cd frontend
npm test -- src/api/etmf.test.ts src/views/ia/EtmfPlaceholder.test.ts src/utils/projectRoutePermissions.test.ts
```
Expected: pass.
**Step 3: Run UI contract check if available**
```bash
cd frontend
npm run verify-ui-contract
```
Expected: pass.
**Step 4: Manual browser check**
Start the dev server and open `/etmf` with an active study:
```bash
cd frontend
npm run dev
```
Expected:
- eTMF page is not blank.
- Directory tree is visible.
- Selecting a node updates the document list.
- Center filter does not break layout.
- Text does not overlap at desktop and mobile widths.
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPatch = vi.fn();
vi.mock("./axios", () => ({
apiGet,
apiPost,
apiPatch,
}));
describe("etmf api", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses canonical eTMF URLs", async () => {
const { fetchEtmfTree, fetchEtmfNodeDocuments, createEtmfNode, updateEtmfNode, createEtmfDocument } = await import("./etmf");
fetchEtmfTree("study-1");
fetchEtmfNodeDocuments("node-1", { site_id: "site-1" });
createEtmfNode({ study_id: "study-1", code: "01", name: "项目管理" });
updateEtmfNode("node-1", { name: "项目管理文件" });
createEtmfDocument("node-1", {
trial_id: "study-1",
doc_no: "DOC-001",
doc_type: "Protocol",
title: "研究方案",
scope_type: "GLOBAL",
});
expect(apiGet).toHaveBeenCalledWith("/api/v1/etmf/tree", { params: { study_id: "study-1" } });
expect(apiGet).toHaveBeenCalledWith("/api/v1/etmf/nodes/node-1/documents", { params: { site_id: "site-1" } });
expect(apiPost).toHaveBeenCalledWith("/api/v1/etmf/nodes", { study_id: "study-1", code: "01", name: "项目管理" });
expect(apiPatch).toHaveBeenCalledWith("/api/v1/etmf/nodes/node-1", { name: "项目管理文件" });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/etmf/nodes/node-1/documents",
expect.objectContaining({ doc_no: "DOC-001" })
);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import type { DocumentSummary } from "../types/documents";
import type { EtmfDocumentCreatePayload, EtmfNodeCreatePayload, EtmfNodeUpdatePayload, EtmfTreeNode } from "../types/etmf";
export const fetchEtmfTree = (studyId: string) =>
apiGet<EtmfTreeNode[]>("/api/v1/etmf/tree", { params: { study_id: studyId } });
export const fetchEtmfNodeDocuments = (nodeId: string, params: Record<string, any> = {}) =>
apiGet<DocumentSummary[]>(`/api/v1/etmf/nodes/${nodeId}/documents`, { params });
export const createEtmfNode = (payload: EtmfNodeCreatePayload) =>
apiPost<EtmfTreeNode>("/api/v1/etmf/nodes", payload);
export const updateEtmfNode = (nodeId: string, payload: EtmfNodeUpdatePayload) =>
apiPatch<EtmfTreeNode>(`/api/v1/etmf/nodes/${nodeId}`, payload);
export const createEtmfDocument = (nodeId: string, payload: EtmfDocumentCreatePayload) =>
apiPost<DocumentSummary>(`/api/v1/etmf/nodes/${nodeId}/documents`, payload);
+1
View File
@@ -18,6 +18,7 @@ export interface DocumentSummary {
id: string;
trial_id: string;
site_id?: string | null;
etmf_node_id?: string | null;
doc_type: string;
title: string;
scope_type: DocumentScopeType;
+63
View File
@@ -0,0 +1,63 @@
import type { DocumentScopeType, DocumentSummary } from "./documents";
export type EtmfNodeStatus = "NOT_REQUIRED" | "MISSING" | "UPLOADED" | "EFFECTIVE" | "INACTIVE";
export interface EtmfTreeNode {
id: string;
study_id: string;
parent_id?: string | null;
code: string;
name: string;
description?: string | null;
scope_type: DocumentScopeType;
required: boolean;
expected_doc_type?: string | null;
sort_order: number;
is_active: boolean;
status: EtmfNodeStatus;
document_count: number;
effective_document_count: number;
created_at: string;
updated_at: string;
children: EtmfTreeNode[];
}
export interface EtmfNodeCreatePayload {
study_id: string;
parent_id?: string | null;
code: string;
name: string;
description?: string | null;
scope_type?: DocumentScopeType;
required?: boolean;
expected_doc_type?: string | null;
sort_order?: number;
is_active?: boolean;
}
export interface EtmfNodeUpdatePayload {
parent_id?: string | null;
code?: string;
name?: string;
description?: string | null;
scope_type?: DocumentScopeType;
required?: boolean;
expected_doc_type?: string | null;
sort_order?: number;
is_active?: boolean;
}
export type EtmfDocumentCreatePayload = {
trial_id: string;
site_id?: string | null;
doc_no: string;
doc_type: string;
title: string;
scope_type: DocumentScopeType;
owner_id?: string | null;
description?: string | null;
};
export interface EtmfNodeDocumentList {
documents: DocumentSummary[];
}
@@ -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, "./EtmfPlaceholder.vue"), "utf8");
describe("EtmfPlaceholder", () => {
it("renders a functional eTMF workspace instead of the generic placeholder", () => {
const source = readSource();
expect(source).toContain("fetchEtmfTree");
expect(source).toContain("fetchEtmfNodeDocuments");
expect(source).toContain("createEtmfDocument");
expect(source).toContain("etmf-tree-panel");
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");
});
});
+841 -7
View File
@@ -1,13 +1,847 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.etmf.title"
:subtitle="TEXT.modules.etmf.subtitle"
:list-title="TEXT.modules.etmf.listTitle"
:empty-description="TEXT.modules.etmf.emptyDescription"
/>
<div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush etmf-action-bar">
<div class="etmf-toolbar">
<div class="etmf-toolbar-main">
<div class="etmf-filter-item">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix>
<el-icon><OfficeBuilding /></el-icon>
</template>
<el-option :label="TEXT.common.labels.allSites" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<div class="etmf-filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
</div>
<div class="etmf-toolbar-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button :disabled="!canCreate" @click="openNodeDialog">
<el-icon class="el-icon--left"><FolderAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newNode }}
</el-button>
<el-button type="primary" :disabled="!canCreate || !selectedNode" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</div>
<div class="etmf-status-strip">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-card" :class="`etmf-status-card--${card.key}`">
<div class="status-card-value">{{ card.value }}</div>
<div class="status-card-label">{{ card.label }}</div>
</div>
</div>
</div>
<section class="unified-section section--flush etmf-workspace">
<aside class="etmf-tree-panel">
<div class="panel-heading panel-heading--compact">
<div>
<div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div>
<div class="panel-subtitle">{{ totalNodeCount }} 个目录节点</div>
</div>
</div>
<div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree">
<div class="empty-icon">
<el-icon><FolderAdd /></el-icon>
</div>
<div class="empty-title">暂无 TMF 目录</div>
<div class="empty-desc">先建立项目归档目录再在目录下归档文件</div>
<el-button v-if="canCreate" size="small" type="primary" @click="openNodeDialog">{{ TEXT.modules.etmf.actions.newNode }}</el-button>
</div>
<el-tree
v-else
v-loading="treeLoading"
:data="filteredTree"
node-key="id"
:props="treeProps"
default-expand-all
highlight-current
class="etmf-tree"
@node-click="selectNode"
>
<template #default="{ data }">
<div class="tree-node-row">
<span class="tree-node-code">{{ data.code }}</span>
<span class="tree-node-name">{{ data.name }}</span>
<span class="tree-node-count">{{ data.document_count }}</span>
<el-tag size="small" effect="plain" :type="statusType(data.status)">
{{ statusLabel(data.status) }}
</el-tag>
</div>
</template>
</el-tree>
</aside>
<main class="etmf-document-panel">
<div class="panel-heading">
<div>
<div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div>
<div class="panel-subtitle">
{{ selectedNode ? `${selectedNode.code} · ${scopeLabel(selectedNode.scope_type)}` : TEXT.modules.etmf.selectNodeHint }}
</div>
</div>
<div v-if="selectedNode" class="document-panel-meta">
<span>{{ selectedNode.document_count }} 份文件</span>
<span>{{ selectedNode.effective_document_count }} 个生效版本</span>
<el-tag effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
</div>
<el-table
:data="documents"
v-loading="documentLoading"
class="ctms-table etmf-document-table"
table-layout="fixed"
@row-click="goDocument"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="120">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="150">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div class="empty-icon">
<el-icon><DocumentAdd /></el-icon>
</div>
<div class="empty-title">{{ selectedNode ? TEXT.modules.etmf.emptyDocuments : TEXT.modules.etmf.selectNodeHint }}</div>
<div class="empty-desc">{{ selectedNode ? "当前目录下还没有归档文件。" : "选择目录后可查看文件、版本与中心范围。" }}</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</template>
</el-table>
</main>
<aside class="etmf-node-detail">
<div class="panel-title">{{ TEXT.modules.etmf.detailTitle }}</div>
<template v-if="selectedNode">
<dl class="node-meta">
<div>
<dt>{{ TEXT.modules.etmf.fields.code }}</dt>
<dd>{{ selectedNode.code }}</dd>
</div>
<div>
<dt>{{ TEXT.modules.etmf.fields.scope }}</dt>
<dd>{{ scopeLabel(selectedNode.scope_type) }}</dd>
</div>
<div>
<dt>{{ TEXT.modules.etmf.fields.required }}</dt>
<dd>{{ selectedNode.required ? TEXT.common.labels.yes : TEXT.common.labels.no }}</dd>
</div>
<div>
<dt>{{ TEXT.modules.etmf.fields.documentCount }}</dt>
<dd>{{ selectedNode.document_count }}</dd>
</div>
<div>
<dt>{{ TEXT.modules.etmf.fields.effectiveCount }}</dt>
<dd>{{ selectedNode.effective_document_count }}</dd>
</div>
</dl>
<div class="node-status-note" :class="`node-status-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</div>
</template>
<div v-else class="etmf-empty-block etmf-empty-block--detail">
<div class="empty-icon">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="empty-title">等待选择目录</div>
<div class="empty-desc">{{ TEXT.modules.etmf.selectNodeHint }}</div>
</div>
</aside>
</section>
</div>
<el-dialog v-model="nodeDialogVisible" :title="TEXT.modules.etmf.dialog.nodeTitle" width="520px" destroy-on-close class="ctms-dialog">
<el-form :model="nodeForm" label-width="96px" class="ctms-form">
<el-form-item :label="TEXT.modules.etmf.fields.parent">
<el-input :model-value="selectedNode?.name || TEXT.modules.etmf.rootNode" disabled />
</el-form-item>
<el-form-item :label="TEXT.modules.etmf.fields.code">
<el-input v-model="nodeForm.code" />
</el-form-item>
<el-form-item :label="TEXT.modules.etmf.fields.name">
<el-input v-model="nodeForm.name" />
</el-form-item>
<el-form-item :label="TEXT.modules.etmf.fields.scope">
<el-select v-model="nodeForm.scope_type" style="width: 100%">
<el-option :label="TEXT.enums.scopeType.GLOBAL" value="GLOBAL" />
<el-option :label="TEXT.enums.scopeType.SITE" value="SITE" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.modules.etmf.fields.required">
<el-switch v-model="nodeForm.required" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="nodeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="savingNode" @click="submitNode">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="documentDialogVisible" :title="TEXT.modules.etmf.dialog.documentTitle" width="560px" destroy-on-close class="ctms-dialog">
<el-form :model="documentForm" label-width="96px" class="ctms-form">
<el-form-item :label="TEXT.modules.etmf.fields.node">
<el-input :model-value="selectedNode?.name || TEXT.common.fallback" disabled />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docNo">
<el-input v-model="documentForm.doc_no" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.title">
<el-input v-model="documentForm.title" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType">
<el-select v-model="documentForm.doc_type" style="width: 100%">
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-if="selectedNode?.scope_type === 'SITE'" :label="TEXT.common.fields.site">
<el-select v-model="documentForm.site_id" filterable style="width: 100%">
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="documentDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="savingDocument" @click="submitDocument">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { CircleCheck, DocumentAdd, FolderAdd, OfficeBuilding, Refresh } from "@element-plus/icons-vue";
import { createEtmfDocument, createEtmfNode, fetchEtmfNodeDocuments, fetchEtmfTree } from "../../api/etmf";
import { fetchSites } from "../../api/sites";
import { TEXT } from "../../locales";
import { useStudyStore } from "../../store/study";
import type { DocumentScopeType, DocumentSummary } from "../../types/documents";
import type { EtmfNodeStatus, EtmfTreeNode } from "../../types/etmf";
import { displayText } from "../../utils/display";
import { usePermission } from "../../utils/permission";
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const treeLoading = ref(false);
const documentLoading = ref(false);
const savingNode = ref(false);
const savingDocument = ref(false);
const tree = ref<EtmfTreeNode[]>([]);
const documents = ref<DocumentSummary[]>([]);
const selectedNode = ref<EtmfTreeNode | null>(null);
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const nodeDialogVisible = ref(false);
const documentDialogVisible = ref(false);
const filters = reactive({
siteId: study.currentSite?.id || "",
status: "",
});
const nodeForm = reactive({
code: "",
name: "",
scope_type: "GLOBAL" as DocumentScopeType,
required: false,
});
const documentForm = reactive({
doc_no: "",
title: "",
doc_type: "",
site_id: "",
});
const treeProps = { label: "name", children: "children" };
const canCreate = computed(() => can("documents.create"));
const studyId = computed(() => study.currentStudy?.id || "");
const statusOptions = computed(() =>
Object.entries(TEXT.modules.etmf.status).map(([value, label]) => ({ value, label }))
);
const docTypeOptions = Object.entries(TEXT.enums.documentType).map(([value, label]) => ({ value, label }));
const siteMap = computed(() =>
siteOptions.value.reduce((acc: Record<string, string>, site) => {
acc[site.id] = site.name;
return acc;
}, {})
);
const flattenNodes = (nodes: EtmfTreeNode[]): EtmfTreeNode[] =>
nodes.flatMap((node) => [node, ...flattenNodes(node.children || [])]);
const flatNodes = computed(() => flattenNodes(tree.value));
const totalNodeCount = computed(() => flatNodes.value.length);
const overviewCards = computed(() => [
{ key: "nodes", label: "目录", value: totalNodeCount.value },
{ key: "missing", label: TEXT.modules.etmf.status.MISSING, value: flatNodes.value.filter((node) => node.status === "MISSING").length },
{ key: "uploaded", label: TEXT.modules.etmf.status.UPLOADED, value: flatNodes.value.filter((node) => node.status === "UPLOADED").length },
{ key: "effective", label: TEXT.modules.etmf.status.EFFECTIVE, value: flatNodes.value.filter((node) => node.status === "EFFECTIVE").length },
]);
const filterTreeByStatus = (nodes: EtmfTreeNode[]): EtmfTreeNode[] => {
if (!filters.status) return nodes;
return nodes
.map((node) => {
const children = filterTreeByStatus(node.children || []);
if (node.status === filters.status || children.length) {
return { ...node, children };
}
return null;
})
.filter((node): node is EtmfTreeNode => !!node);
};
const filteredTree = computed(() => filterTreeByStatus(tree.value));
const firstNode = (nodes: EtmfTreeNode[]): EtmfTreeNode | null => {
for (const node of nodes) {
return node;
}
return null;
};
const statusLabel = (status: EtmfNodeStatus) => TEXT.modules.etmf.status[status] || status;
const statusType = (status: EtmfNodeStatus) => {
if (status === "EFFECTIVE") return "success";
if (status === "MISSING") return "danger";
if (status === "UPLOADED") return "warning";
if (status === "INACTIVE") return "info";
return "";
};
const statusDescription = (status: EtmfNodeStatus) => TEXT.modules.etmf.statusDescriptions[status] || "";
const scopeLabel = (scope: DocumentScopeType) => TEXT.enums.scopeType[scope] || scope;
const displaySite = (siteId?: string | null) => (siteId ? siteMap.value[siteId] || siteId : TEXT.common.fallback);
const formatDate = (value?: string | null) => (value ? value.slice(0, 10) : TEXT.common.fallback);
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
siteOptions.value = Array.isArray(data) ? data : data.items || [];
} catch {
siteOptions.value = [];
}
};
const loadNodeDocuments = async () => {
if (!selectedNode.value) {
documents.value = [];
return;
}
documentLoading.value = true;
try {
const params = filters.siteId ? { site_id: filters.siteId } : {};
const { data } = await fetchEtmfNodeDocuments(selectedNode.value.id, params);
documents.value = Array.isArray(data) ? data : [];
} catch (e: any) {
documents.value = [];
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
documentLoading.value = false;
}
};
const loadTree = async () => {
if (!studyId.value) return;
treeLoading.value = true;
try {
const { data } = await fetchEtmfTree(studyId.value);
tree.value = data || [];
selectedNode.value = selectedNode.value
? findNode(tree.value, selectedNode.value.id) || firstNode(tree.value)
: firstNode(tree.value);
await loadNodeDocuments();
} catch (e: any) {
tree.value = [];
selectedNode.value = null;
documents.value = [];
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
treeLoading.value = false;
}
};
const findNode = (nodes: EtmfTreeNode[], id: string): EtmfTreeNode | null => {
for (const node of nodes) {
if (node.id === id) return node;
const matched = findNode(node.children || [], id);
if (matched) return matched;
}
return null;
};
const load = async () => {
await loadSites();
await loadTree();
};
const selectNode = async (node: EtmfTreeNode) => {
selectedNode.value = node;
await loadNodeDocuments();
};
const resetFilters = () => {
filters.siteId = "";
filters.status = "";
loadNodeDocuments();
};
const openNodeDialog = () => {
nodeForm.code = "";
nodeForm.name = "";
nodeForm.scope_type = selectedNode.value?.scope_type || "GLOBAL";
nodeForm.required = false;
nodeDialogVisible.value = true;
};
const submitNode = async () => {
if (!studyId.value || !nodeForm.code.trim() || !nodeForm.name.trim()) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
savingNode.value = true;
try {
await createEtmfNode({
study_id: studyId.value,
parent_id: selectedNode.value?.id || null,
code: nodeForm.code.trim(),
name: nodeForm.name.trim(),
scope_type: nodeForm.scope_type,
required: nodeForm.required,
});
nodeDialogVisible.value = false;
await loadTree();
ElMessage.success(TEXT.common.messages.createSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
savingNode.value = false;
}
};
const openDocumentDialog = () => {
if (!selectedNode.value) return;
documentForm.doc_no = "";
documentForm.title = "";
documentForm.doc_type = selectedNode.value.expected_doc_type || "";
documentForm.site_id = filters.siteId || "";
documentDialogVisible.value = true;
};
const submitDocument = async () => {
if (!studyId.value || !selectedNode.value || !documentForm.doc_no.trim() || !documentForm.title.trim() || !documentForm.doc_type) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
if (selectedNode.value.scope_type === "SITE" && !documentForm.site_id) {
ElMessage.warning(TEXT.modules.etmf.messages.siteRequired);
return;
}
savingDocument.value = true;
try {
await createEtmfDocument(selectedNode.value.id, {
trial_id: studyId.value,
site_id: selectedNode.value.scope_type === "SITE" ? documentForm.site_id : null,
doc_no: documentForm.doc_no.trim(),
doc_type: documentForm.doc_type,
title: documentForm.title.trim(),
scope_type: selectedNode.value.scope_type,
});
documentDialogVisible.value = false;
await loadTree();
ElMessage.success(TEXT.common.messages.createSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
savingDocument.value = false;
}
};
const goDocument = (row: DocumentSummary) => {
if (row?.id) router.push(`/documents/${row.id}`);
};
watch(
() => study.currentSite,
(site) => {
filters.siteId = site?.id || "";
loadNodeDocuments();
}
);
watch(studyId, load);
onMounted(load);
</script>
<style scoped>
.etmf-action-bar {
display: grid;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid var(--ctms-border-light);
}
.etmf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
width: 100%;
}
.etmf-toolbar-main,
.etmf-toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.etmf-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.etmf-filter-item {
width: 240px;
min-width: 180px;
}
.etmf-filter-item :deep(.el-select) {
width: 100%;
}
.etmf-status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
}
.etmf-status-card {
min-height: 64px;
display: grid;
align-content: center;
gap: 2px;
padding: 10px 14px;
border: 1px solid #e4eaf2;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.status-card-value {
font-size: 22px;
line-height: 1;
font-weight: 750;
color: #172033;
}
.status-card-label {
font-size: 12px;
color: #68758a;
}
.etmf-status-card--missing .status-card-value {
color: #c24141;
}
.etmf-status-card--uploaded .status-card-value {
color: #b7791f;
}
.etmf-status-card--effective .status-card-value {
color: #24764b;
}
.etmf-workspace {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);
min-height: calc(100vh - 260px);
border-top: 0;
background: #fff;
}
.etmf-tree-panel,
.etmf-document-panel,
.etmf-node-detail {
min-width: 0;
padding: 16px;
}
.etmf-tree-panel {
border-right: 1px solid var(--ctms-border-light);
background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fc 100%);
}
.etmf-document-panel {
border-right: 1px solid var(--ctms-border-light);
background: #fff;
}
.panel-heading {
min-height: 48px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.panel-heading--compact {
min-height: 38px;
margin-bottom: 10px;
}
.panel-title {
font-size: 15px;
font-weight: 700;
color: var(--ctms-text-main);
}
.panel-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.etmf-tree {
--el-tree-node-hover-bg-color: #eef4ff;
background: transparent;
padding-top: 2px;
}
.etmf-tree :deep(.el-tree-node__content) {
height: 34px;
border-radius: 7px;
margin: 2px 0;
}
.etmf-tree :deep(.is-current > .el-tree-node__content) {
background: #eaf2ff;
box-shadow: inset 3px 0 0 #4f7ecf;
}
.tree-node-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr) 28px max-content;
align-items: center;
gap: 8px;
padding-right: 8px;
}
.tree-node-code {
font-size: 12px;
font-weight: 700;
color: #315f9f;
}
.tree-node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
}
.tree-node-count {
height: 20px;
min-width: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #edf2f7;
color: #526174;
font-size: 12px;
font-weight: 650;
}
.document-panel-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
font-size: 12px;
color: #69778d;
}
.etmf-document-table {
width: 100%;
}
.node-meta {
display: grid;
gap: 12px;
margin: 16px 0;
}
.node-meta div {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 10px;
}
.node-meta dt {
color: var(--ctms-text-secondary);
}
.node-meta dd {
min-width: 0;
margin: 0;
font-weight: 600;
color: var(--ctms-text-main);
overflow-wrap: anywhere;
}
.node-status-note {
border-left: 3px solid #8aa0bd;
background: #f6f8fb;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--ctms-text-main);
}
.node-status-note--effective {
border-left-color: #2f8f5b;
}
.node-status-note--missing {
border-left-color: #c84646;
}
.node-status-note--uploaded {
border-left-color: #c58b20;
}
.etmf-empty-block {
min-height: 168px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 22px;
text-align: center;
color: #7b8797;
}
.etmf-empty-block--tree {
min-height: 280px;
border: 1px dashed #d8e0eb;
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
}
.etmf-empty-block--documents {
min-height: 420px;
}
.etmf-empty-block--detail {
min-height: 280px;
}
.empty-icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #eef3f9;
color: #6b7c91;
font-size: 22px;
}
.empty-title {
font-size: 15px;
font-weight: 700;
color: #2f3a4c;
}
.empty-desc {
max-width: 320px;
font-size: 13px;
line-height: 1.55;
color: #7b8797;
}
@media (max-width: 1180px) {
.etmf-toolbar {
align-items: stretch;
flex-direction: column;
}
.etmf-toolbar-actions {
justify-content: flex-start;
}
.etmf-workspace {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
}
.etmf-node-detail {
grid-column: 1 / -1;
border-top: 1px solid var(--ctms-border-light);
}
}
@media (max-width: 760px) {
.etmf-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.etmf-toolbar-main {
flex-direction: column;
align-items: stretch;
}
.etmf-filter-item {
width: 100%;
}
.etmf-workspace {
grid-template-columns: 1fr;
}
.etmf-tree-panel,
.etmf-document-panel {
border-right: 0;
border-bottom: 1px solid var(--ctms-border-light);
}
}
</style>