Files
ctms/frontend/src/views/ia/EtmfPlaceholder.vue
T
2026-07-07 11:11:43 +08:00

1004 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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 size="small" 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 size="small" 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-status-inline">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-badge" :class="`etmf-status-badge--${card.key}`">
<span class="status-badge-value">{{ card.value }}</span>
<span class="status-badge-label">{{ card.label }}</span>
</div>
</div>
<div class="etmf-toolbar-actions">
<el-button size="small" :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button v-if="canCreate && selectedNode" size="small" type="primary" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</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>
</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 class="panel-heading-left">
<div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div>
<span v-if="selectedNode" class="panel-subtitle">{{ selectedNode.code }} · {{ scopeLabel(selectedNode.scope_type) }}</span>
</div>
<div v-if="selectedNode" class="document-panel-meta">
<span>{{ selectedNode.document_count }} 份文件</span>
<span>{{ selectedNode.effective_document_count }} 生效</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
</div>
<section v-if="selectedNode" class="document-status-summary">
<div class="status-summary-head">
<span class="status-summary-label">{{ TEXT.modules.etmf.detailTitle }}</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
<span class="status-summary-note" :class="`status-summary-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</span>
<dl class="status-summary-list">
<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>
</section>
<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 min-width="1" />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" min-width="1">
<template #default="{ row }">
<el-tag effect="plain" type="info" size="small">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="1" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" min-width="1">
<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" min-width="1">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div v-if="selectedNode" class="empty-title">{{ TEXT.modules.etmf.emptyDocuments }}</div>
<div v-if="selectedNode" class="empty-desc">当前目录下还没有归档文件</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>
</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 { 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 = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
nodeForm.code = "";
nodeForm.name = "";
nodeForm.scope_type = selectedNode.value?.scope_type || "GLOBAL";
nodeForm.required = false;
nodeDialogVisible.value = true;
};
const submitNode = async () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
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 (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
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 (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
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>
/* ==================== 高度链:让工作区撑满页面剩余空间 ==================== */
/* .page 本身占满路由容器给它的全部高度 */
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* unified-shellmain-content-flat)是直接子元素,flex:1 让它撑满 .page */
.page :deep(.main-content-flat) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ==================== 顶部单行紧凑工具栏 ==================== */
.etmf-action-bar {
padding: 8px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
flex-shrink: 0;
}
.etmf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-width: 0;
}
.etmf-toolbar-main,
.etmf-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.etmf-filter-item {
width: 180px;
min-width: 140px;
}
.etmf-filter-item :deep(.el-select) {
width: 100%;
}
/* 内联状态徽章条 */
.etmf-status-inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
}
.etmf-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 10px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.07);
white-space: nowrap;
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
}
/* 左侧彩色竖线 */
.etmf-status-badge::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 8px 0 0 8px;
background: #c8d8ef;
}
.etmf-status-badge:hover {
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
}
.status-badge-value {
font-size: 17px;
font-weight: 900;
color: #1e2a3a;
line-height: 1;
letter-spacing: -0.03em;
min-width: 1ch;
text-align: right;
}
.status-badge-label {
font-size: 11px;
font-weight: 600;
color: #8a9ab0;
letter-spacing: 0.01em;
line-height: 1.2;
}
/* 目录 — 蓝色 */
.etmf-status-badge--nodes::before { background: #4f7ecf; }
.etmf-status-badge--nodes .status-badge-value { color: #1e3a6e; }
/* 缺失 — 红色 */
.etmf-status-badge--missing {
background: linear-gradient(90deg, #fff8f8 0%, #fff 50%);
border-color: rgba(224, 82, 82, 0.2);
}
.etmf-status-badge--missing::before { background: #e05252; }
.etmf-status-badge--missing .status-badge-value { color: #c03434; }
.etmf-status-badge--missing .status-badge-label { color: #c06060; }
/* 已上传 — 琥珀色 */
.etmf-status-badge--uploaded {
background: linear-gradient(90deg, #fffbf2 0%, #fff 50%);
border-color: rgba(212, 146, 10, 0.2);
}
.etmf-status-badge--uploaded::before { background: #d4920a; }
.etmf-status-badge--uploaded .status-badge-value { color: #a06b06; }
.etmf-status-badge--uploaded .status-badge-label { color: #b08030; }
/* 已生效 — 绿色 */
.etmf-status-badge--effective {
background: linear-gradient(90deg, #f4fdf8 0%, #fff 50%);
border-color: rgba(45, 172, 110, 0.2);
}
.etmf-status-badge--effective::before { background: #2dac6e; }
.etmf-status-badge--effective .status-badge-value { color: #1a7a4e; }
.etmf-status-badge--effective .status-badge-label { color: #4a9a70; }
/* 旧状态卡片已替换为内联徽章,保留空占位避免其他引用报错 */
.etmf-workspace {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 0;
flex: 1;
border-top: 0;
background: #fff;
overflow: hidden;
}
.etmf-tree-panel,
.etmf-document-panel {
min-width: 0;
min-height: 0;
padding: 12px 14px;
overflow-y: auto;
}
.etmf-tree-panel {
border-right: 1px solid #e4e8ef;
background: linear-gradient(180deg, #f9fbff 0%, #f4f7fd 100%);
}
.etmf-document-panel {
background: #fff;
overflow-x: hidden;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(79, 126, 207, 0.08);
}
.panel-heading--compact {
margin-bottom: 8px;
}
.panel-heading-left {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
overflow: hidden;
}
.panel-title {
font-size: 13px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.01em;
}
.panel-subtitle {
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 6px;
}
.etmf-tree {
--el-tree-node-hover-bg-color: rgba(79, 126, 207, 0.07);
background: transparent;
padding-top: 2px;
}
.etmf-tree :deep(.el-tree-node__content) {
height: 30px;
border-radius: 6px;
margin: 1px 0;
transition: background 0.15s ease;
}
.etmf-tree :deep(.is-current > .el-tree-node__content) {
background: linear-gradient(90deg, rgba(79, 126, 207, 0.12) 0%, rgba(79, 126, 207, 0.04) 100%);
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) 22px max-content;
align-items: center;
gap: 6px;
padding-right: 6px;
}
.tree-node-code {
font-size: 10px;
font-weight: 800;
color: #4f7ecf;
background: rgba(79, 126, 207, 0.1);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.tree-node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
font-size: 12px;
}
.tree-node-count {
height: 18px;
min-width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: linear-gradient(135deg, #e8edf5, #dce4f0);
color: #526174;
font-size: 10px;
font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.document-panel-meta {
display: flex;
align-items: center;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 6px;
font-size: 11px;
font-weight: 500;
color: #69778d;
flex-shrink: 0;
}
.document-status-summary {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
padding: 8px 12px;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 8px;
background: linear-gradient(135deg, #f9fbff 0%, #f3f7fd 100%);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.status-summary-head {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-summary-label {
color: var(--ctms-text-main);
font-size: 12px;
font-weight: 700;
}
.status-summary-note {
font-size: 11px;
color: var(--ctms-text-secondary);
line-height: 1.4;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-summary-note--effective { color: #1a5c3a; }
.status-summary-note--missing { color: #7a2020; }
.status-summary-note--uploaded { color: #7a4e0a; }
.status-summary-list {
display: flex;
gap: 6px;
margin: 0;
flex-shrink: 0;
}
.status-summary-list div {
display: flex;
align-items: baseline;
gap: 3px;
min-width: 0;
padding: 3px 8px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(79, 126, 207, 0.1);
}
.status-summary-list dt {
color: var(--ctms-text-secondary);
font-size: 10px;
font-weight: 600;
order: 2;
}
.status-summary-list dd {
min-width: 0;
margin: 0;
color: var(--ctms-text-main);
font-size: 13px;
font-weight: 800;
order: 1;
}
.etmf-document-table {
width: 100%;
}
/* 文档表格行悬停效果 */
.etmf-document-table :deep(.el-table__body tr:hover > td) {
background: rgba(79, 126, 207, 0.04) !important;
cursor: pointer;
}
/* 表格头部紧凑化 */
.etmf-document-table :deep(th.el-table__cell) {
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
color: #526174;
background: #f8fafc;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.etmf-document-table :deep(td.el-table__cell) {
padding: 8px 12px;
font-size: 13px;
}
/* 隐藏表格底部分隔线(el-table inner-wrapper 的 ::before 伪元素) */
.etmf-document-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
/* 隐藏横向滚动条 */
.etmf-document-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.etmf-document-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.etmf-empty-block {
min-height: 120px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 20px;
text-align: center;
color: #7b8797;
}
.etmf-empty-block--tree {
min-height: 200px;
border-radius: 10px;
background: linear-gradient(135deg, #f4f7fd 0%, #eef3fb 100%);
}
.etmf-empty-block--documents {
min-height: 300px;
}
.empty-icon {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
font-size: 18px;
box-shadow: 0 3px 8px rgba(79, 126, 207, 0.15);
}
.empty-title {
font-size: 13px;
font-weight: 700;
color: #2a3550;
letter-spacing: -0.01em;
}
.empty-desc {
max-width: 280px;
font-size: 12px;
line-height: 1.5;
color: #7b8797;
}
@media (max-width: 900px) {
.etmf-toolbar {
flex-wrap: wrap;
}
.etmf-status-inline {
display: none;
}
.etmf-workspace {
grid-template-columns: 240px minmax(0, 1fr);
}
.status-summary-list {
display: none;
}
.document-status-summary {
flex-wrap: wrap;
}
}
@media (max-width: 700px) {
.etmf-toolbar {
flex-direction: column;
align-items: stretch;
}
.etmf-toolbar-main {
flex-wrap: wrap;
}
.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>