@@ -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 : 12 px ;
padding : 14 px 20 px ;
background : # f8fafc ;
border - bottom : 1 px solid var ( -- ctms - border - light ) ;
}
. etmf - toolbar {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 14 px ;
width : 100 % ;
}
. etmf - toolbar - main ,
. etmf - toolbar - actions {
display : flex ;
align - items : center ;
gap : 10 px ;
min - width : 0 ;
}
. etmf - toolbar - actions {
flex - wrap : wrap ;
justify - content : flex - end ;
}
. etmf - filter - item {
width : 240 px ;
min - width : 180 px ;
}
. etmf - filter - item : deep ( . el - select ) {
width : 100 % ;
}
. etmf - status - strip {
display : grid ;
grid - template - columns : repeat ( 4 , minmax ( 120 px , 1 fr ) ) ;
gap : 10 px ;
}
. etmf - status - card {
min - height : 64 px ;
display : grid ;
align - content : center ;
gap : 2 px ;
padding : 10 px 14 px ;
border : 1 px solid # e4eaf2 ;
border - radius : 8 px ;
background : # fff ;
box - shadow : 0 1 px 2 px rgba ( 15 , 23 , 42 , 0.03 ) ;
}
. status - card - value {
font - size : 22 px ;
line - height : 1 ;
font - weight : 750 ;
color : # 172033 ;
}
. status - card - label {
font - size : 12 px ;
color : # 68758 a ;
}
. 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 : # 24764 b ;
}
. etmf - workspace {
display : grid ;
grid - template - columns : minmax ( 280 px , 340 px ) minmax ( 520 px , 1 fr ) minmax ( 260 px , 320 px ) ;
min - height : calc ( 100 vh - 260 px ) ;
border - top : 0 ;
background : # fff ;
}
. etmf - tree - panel ,
. etmf - document - panel ,
. etmf - node - detail {
min - width : 0 ;
padding : 16 px ;
}
. etmf - tree - panel {
border - right : 1 px solid var ( -- ctms - border - light ) ;
background : linear - gradient ( 180 deg , # fbfcfe 0 % , # f7f9fc 100 % ) ;
}
. etmf - document - panel {
border - right : 1 px solid var ( -- ctms - border - light ) ;
background : # fff ;
}
. panel - heading {
min - height : 48 px ;
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
gap : 12 px ;
margin - bottom : 12 px ;
}
. panel - heading -- compact {
min - height : 38 px ;
margin - bottom : 10 px ;
}
. panel - title {
font - size : 15 px ;
font - weight : 700 ;
color : var ( -- ctms - text - main ) ;
}
. panel - subtitle {
margin - top : 4 px ;
font - size : 12 px ;
color : var ( -- ctms - text - secondary ) ;
}
. etmf - tree {
-- el - tree - node - hover - bg - color : # eef4ff ;
background : transparent ;
padding - top : 2 px ;
}
. etmf - tree : deep ( . el - tree - node _ _content ) {
height : 34 px ;
border - radius : 7 px ;
margin : 2 px 0 ;
}
. etmf - tree : deep ( . is - current > . el - tree - node _ _content ) {
background : # eaf2ff ;
box - shadow : inset 3 px 0 0 # 4 f7ecf ;
}
. tree - node - row {
width : 100 % ;
min - width : 0 ;
display : grid ;
grid - template - columns : max - content minmax ( 0 , 1 fr ) 28 px max - content ;
align - items : center ;
gap : 8 px ;
padding - right : 8 px ;
}
. tree - node - code {
font - size : 12 px ;
font - weight : 700 ;
color : # 315 f9f ;
}
. tree - node - name {
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
color : var ( -- ctms - text - main ) ;
}
. tree - node - count {
height : 20 px ;
min - width : 22 px ;
display : inline - flex ;
align - items : center ;
justify - content : center ;
border - radius : 999 px ;
background : # edf2f7 ;
color : # 526174 ;
font - size : 12 px ;
font - weight : 650 ;
}
. document - panel - meta {
display : flex ;
align - items : center ;
flex - wrap : wrap ;
justify - content : flex - end ;
gap : 8 px ;
font - size : 12 px ;
color : # 69778 d ;
}
. etmf - document - table {
width : 100 % ;
}
. node - meta {
display : grid ;
gap : 12 px ;
margin : 16 px 0 ;
}
. node - meta div {
display : grid ;
grid - template - columns : 96 px minmax ( 0 , 1 fr ) ;
gap : 10 px ;
}
. 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 : 3 px solid # 8 aa0bd ;
background : # f6f8fb ;
padding : 10 px 12 px ;
font - size : 13 px ;
line - height : 1.5 ;
color : var ( -- ctms - text - main ) ;
}
. node - status - note -- effective {
border - left - color : # 2 f8f5b ;
}
. node - status - note -- missing {
border - left - color : # c84646 ;
}
. node - status - note -- uploaded {
border - left - color : # c58b20 ;
}
. etmf - empty - block {
min - height : 168 px ;
display : grid ;
place - items : center ;
align - content : center ;
gap : 8 px ;
padding : 22 px ;
text - align : center ;
color : # 7 b8797 ;
}
. etmf - empty - block -- tree {
min - height : 280 px ;
border : 1 px dashed # d8e0eb ;
border - radius : 8 px ;
background : rgba ( 255 , 255 , 255 , 0.68 ) ;
}
. etmf - empty - block -- documents {
min - height : 420 px ;
}
. etmf - empty - block -- detail {
min - height : 280 px ;
}
. empty - icon {
width : 44 px ;
height : 44 px ;
display : inline - flex ;
align - items : center ;
justify - content : center ;
border - radius : 10 px ;
background : # eef3f9 ;
color : # 6 b7c91 ;
font - size : 22 px ;
}
. empty - title {
font - size : 15 px ;
font - weight : 700 ;
color : # 2 f3a4c ;
}
. empty - desc {
max - width : 320 px ;
font - size : 13 px ;
line - height : 1.55 ;
color : # 7 b8797 ;
}
@ media ( max - width : 1180 px ) {
. etmf - toolbar {
align - items : stretch ;
flex - direction : column ;
}
. etmf - toolbar - actions {
justify - content : flex - start ;
}
. etmf - workspace {
grid - template - columns : minmax ( 240 px , 300 px ) minmax ( 0 , 1 fr ) ;
}
. etmf - node - detail {
grid - column : 1 / - 1 ;
border - top : 1 px solid var ( -- ctms - border - light ) ;
}
}
@ media ( max - width : 760 px ) {
. etmf - status - strip {
grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ;
}
. etmf - toolbar - main {
flex - direction : column ;
align - items : stretch ;
}
. etmf - filter - item {
width : 100 % ;
}
. etmf - workspace {
grid - template - columns : 1 fr ;
}
. etmf - tree - panel ,
. etmf - document - panel {
border - right : 0 ;
border - bottom : 1 px solid var ( -- ctms - border - light ) ;
}
}
< / style >