新增电子试验主文件目录与文件归档
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user