优化桌面端界面布局

This commit is contained in:
Cheng Zhou
2026-07-07 11:11:43 +08:00
parent 84d5daebab
commit e7b18758b2
30 changed files with 3158 additions and 659 deletions
+261 -6
View File
@@ -1,8 +1,12 @@
<template>
<div class="page ctms-page-shell page--flush medical-consult-page">
<div class="page-bg-dots"></div>
<div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div v-if="!isDesktop" class="page-bg-dots"></div>
<section class="faq-hero">
<h1><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<h1 v-if="!isDesktop"><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<div v-else class="desktop-toolbar-meta">
<span>知识条目</span>
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
</div>
<div class="hero-tools">
<el-autocomplete
v-model="keyword"
@@ -19,7 +23,13 @@
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
<div class="spacer" />
<PermissionAction v-if="isDesktop" action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
<div v-else class="spacer" />
</div>
</section>
@@ -34,7 +44,7 @@
</el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div class="list-toolbar">
<div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
@@ -79,6 +89,7 @@ import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
@@ -87,6 +98,7 @@ import { TEXT } from "../locales";
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
@@ -104,6 +116,14 @@ const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read"));
const canUpdateFaq = computed(() => can("faq.update"));
const canDeleteFaq = computed(() => can("faq.delete"));
const activeCategoryName = computed(() => {
if (!activeCategory.value) return TEXT.common.labels.all;
return categories.value.find((item: any) => item.id === activeCategory.value)?.name || "已筛选分类";
});
const resultSummary = computed(() => {
const suffix = keyword.value.trim() ? ",已应用搜索" : "";
return `${total.value} 条${suffix}`;
});
const loadCategories = async () => {
try {
@@ -247,7 +267,7 @@ onMounted(async () => {
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: -0.02em;
letter-spacing: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -361,6 +381,228 @@ onMounted(async () => {
flex: 1;
}
.desktop-toolbar-meta {
display: flex;
position: relative;
min-width: 160px;
padding-left: 14px;
flex-direction: column;
gap: 2px;
text-align: left;
}
.desktop-toolbar-meta::before {
content: "";
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
width: 4px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
}
.desktop-toolbar-meta span {
color: #11335a;
font-size: 15px;
font-weight: 850;
}
.desktop-toolbar-meta small {
color: #436785;
font-size: 12px;
font-weight: 600;
}
.medical-consult-page--desktop {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 0 !important;
overflow: hidden;
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
}
.medical-consult-page--desktop .faq-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px !important;
border: 1px solid rgba(90, 145, 220, 0.24) !important;
background:
radial-gradient(circle at 0% 0%, rgba(42, 132, 255, 0.22) 0%, transparent 34%),
radial-gradient(circle at 78% 18%, rgba(35, 183, 217, 0.16) 0%, transparent 30%),
linear-gradient(135deg, #ecf7ff 0%, #edf3ff 46%, #f8fcff 100%) !important;
box-shadow:
0 10px 24px rgba(30, 73, 128, 0.08),
0 1px 0 rgba(255, 255, 255, 0.86) inset !important;
text-align: left;
border-radius: 0 !important;
}
.medical-consult-page--desktop .faq-hero::before {
display: none;
}
.medical-consult-page--desktop .hero-tools {
width: auto;
max-width: none;
margin: 0;
justify-content: flex-end;
}
.medical-consult-page--desktop .hero-search {
width: min(460px, 44vw);
}
.medical-consult-page--desktop .hero-search :deep(.el-input__wrapper) {
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 8px 20px rgba(48, 92, 150, 0.08),
0 0 0 1px rgba(93, 146, 214, 0.24) inset;
transition: border-color 0.2s, box-shadow 0.2s;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__inner) {
font-size: 13px;
font-weight: 600;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__prefix) {
color: #6d93bc;
font-size: 15px;
}
.medical-consult-page--desktop .new-consult-btn {
height: 34px;
padding: 0 16px;
border-radius: 8px;
background: linear-gradient(135deg, #2f7be8 0%, #2560bd 100%);
border: none;
box-shadow: 0 10px 18px rgba(47, 123, 232, 0.24);
font-size: 13px;
font-weight: 800;
transition: all 0.2s;
}
.medical-consult-page--desktop .new-consult-btn:hover,
.medical-consult-page--desktop .new-consult-btn:focus {
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
transform: translateY(-1px);
}
.medical-consult-page--desktop .faq-workspace {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
flex: 1 1 auto;
height: 0;
min-height: 0;
margin-top: 0;
border: 1px solid rgba(130, 158, 190, 0.22);
border-radius: 0;
background: #ffffff;
overflow: hidden;
}
.medical-consult-page--desktop .faq-sidebar-col,
.medical-consult-page--desktop .faq-content-col {
width: auto;
max-width: none !important;
flex: initial !important;
}
.medical-consult-page--desktop .faq-sidebar-col {
min-height: 0;
border-right: 1px solid rgba(134, 163, 194, 0.22);
background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.13) 0%, transparent 34%),
linear-gradient(180deg, #e9f4ff 0%, #f6faff 44%, #eaf1fa 100%);
overflow: hidden;
}
.medical-consult-page--desktop .faq-content-col {
display: flex;
min-height: 0;
overflow: hidden;
}
.medical-consult-page--desktop .faq-main {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
flex-direction: column;
border: 0 !important;
padding: 0;
overflow: hidden;
background: #ffffff;
}
.medical-consult-page--desktop .pagination-wrap {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid #edf3fa;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.medical-consult-page--desktop :deep(.faq-category-panel) {
height: 100%;
padding: 12px 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .header) {
padding: 10px 10px;
font-size: 12px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
padding-top: 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu .el-menu-item) {
height: 36px;
margin: 4px 0;
border-radius: 8px;
font-size: 13px;
line-height: 36px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .cat-icon) {
width: 22px;
height: 22px;
border-radius: 7px;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-workspace) {
border-color: #26364a;
background:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-sidebar-col) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .desktop-toolbar-meta span) {
color: #e5edf7;
}
@media (max-width: 1080px) {
.hero-tools {
flex-wrap: wrap;
@@ -376,5 +618,18 @@ onMounted(async () => {
.faq-main {
padding: 18px 16px 0;
}
.medical-consult-page--desktop .faq-workspace {
grid-template-columns: 1fr;
}
.medical-consult-page--desktop .faq-sidebar-col {
border-right: 0;
border-bottom: 1px solid #d9e2ec;
}
.medical-consult-page--desktop .faq-main {
padding: 0;
}
}
</style>
@@ -65,12 +65,33 @@ describe("DocumentList permissions", () => {
expect(source).not.toContain('@row-click="handleRowClick"');
});
it("routes desktop document actions through preview and context menu controls", () => {
it("stretches the desktop document workbench flush to the desktop content edges", () => {
const source = readSource();
expect(source).toContain(":global(.desktop-content:has(> .document-page--desktop))");
expect(source).toContain("padding: 0;");
expect(source).toContain(".document-page--desktop {");
expect(source).toContain("width: 100%;");
expect(source).toContain("height: 100%;");
expect(source).toContain("min-height: 100%;");
expect(source).toContain("margin: 0;");
expect(source).toContain("overflow-x: hidden;");
expect(source).toContain("border-radius: 0 !important;");
expect(source).toContain(".document-page--desktop .main-content-flat");
expect(source).toContain(".document-page--desktop .document-table-section");
expect(source).toContain(".document-page--desktop .document-workbench.is-desktop");
expect(source).toContain(".document-page--desktop .document-table-pane");
expect(source).toContain("flex: 1 1 auto;");
expect(source).toContain("min-height: 0;");
});
it("routes desktop document actions through context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openDocumentContextMenu"');
expect(source).toContain("documentContextMenu");
expect(source).toContain('class="document-context-menu"');
expect(source).not.toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedDocument\"");
expect(source).toContain("@click=\"openSelectedDocumentEditor\"");
expect(source).toContain("@click=\"deleteSelectedDocument\"");
+47 -23
View File
@@ -120,22 +120,6 @@
<dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt>
<dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd>
</dl>
<div class="preview-actions">
<el-button size="small" @click="openSelectedDocument">打开详情</el-button>
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedDocument.site_id)" @click="openSelectedDocumentEditor">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="deleteSelectedDocument"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<span>选择一行查看文档摘要</span>
@@ -620,6 +604,31 @@ watch(
gap: 0;
}
:global(.desktop-content:has(> .document-page--desktop)) {
overflow-x: hidden;
padding: 0;
}
.document-page--desktop {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
overflow-x: hidden;
}
.document-page--desktop .main-content-flat {
display: flex;
width: auto;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
background: #ffffff;
box-shadow: none !important;
overflow-x: hidden;
}
.filter-form {
display: flex;
width: 100%;
@@ -705,6 +714,15 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02);
}
.document-page--desktop .document-table-section {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
box-shadow: none !important;
}
.document-workbench {
min-width: 0;
min-height: 0;
@@ -716,11 +734,24 @@ watch(
min-height: min(570px, calc(100vh - 178px));
}
.document-page--desktop .document-workbench.is-desktop {
height: 100%;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.document-table-pane {
min-width: 0;
outline: none;
}
.document-page--desktop .document-table-pane {
min-height: 0;
overflow: hidden;
background: #ffffff;
}
.document-workbench.is-desktop .document-table-pane {
border-right: 1px solid #e3e9f1;
}
@@ -782,13 +813,6 @@ watch(
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
@@ -35,4 +35,19 @@ describe("ContractFees.vue", () => {
expect(source).not.toContain('router.push("/fees/contracts/new")');
expect(source).not.toContain("`/fees/contracts/${contractId.value}`");
});
it("uses a desktop-only density class for the fee workspace", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain(":class=\"{ 'fee-contracts-page--desktop': isDesktop }\"");
expect(source).toContain("contract-shell");
expect(source).toContain(".fee-contracts-page--desktop .contract-shell");
expect(source).toContain("flex: 1 1 0;");
expect(source).toContain(".fee-contracts-page--desktop :deep(.kpi-card)");
expect(source).toContain("min-height: 104px;");
expect(source).toContain(".fee-contracts-page--desktop .table-empty");
expect(source).toContain("min-height: 132px;");
expect(source).not.toContain("empty-create-btn");
});
});
+75 -3
View File
@@ -1,6 +1,6 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="overview">
<div class="page ctms-page-shell page--flush" :class="{ 'fee-contracts-page--desktop': isDesktop }">
<div class="overview fee-overview">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
@@ -53,7 +53,7 @@
<StateLoading v-else-if="loading" :rows="6" />
<div class="main-content-flat unified-shell" v-else>
<div class="main-content-flat unified-shell contract-shell" v-else>
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item">
@@ -208,9 +208,11 @@ import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update"));
@@ -522,6 +524,76 @@ onMounted(async () => {
letter-spacing: 0.02em;
}
.fee-contracts-page--desktop {
height: 100%;
min-height: 0;
overflow: hidden;
gap: 8px;
}
.fee-contracts-page--desktop .fee-overview {
flex: 0 0 auto;
}
.fee-contracts-page--desktop .contract-shell {
display: flex;
flex: 1 1 0;
min-height: 0;
flex-direction: column;
}
.fee-contracts-page--desktop .contract-table-section {
flex: 1 1 0;
min-height: 0;
background: var(--ctms-bg-card);
}
.fee-contracts-page--desktop .table-empty {
min-height: 132px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-card) {
min-height: 104px;
padding: 12px 16px;
border-radius: 8px;
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-card:hover) {
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-badge) {
margin-bottom: 8px;
padding: 3px 9px;
border-radius: 5px;
}
.fee-contracts-page--desktop :deep(.kpi-title) {
overflow: hidden;
font-size: 15px;
line-height: 1.25;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.fee-contracts-page--desktop :deep(.kpi-footer) {
padding-top: 8px;
}
.fee-contracts-page--desktop :deep(.kpi-value) {
font-size: 30px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-unit) {
font-size: 13px;
}
</style>
<style>
@@ -110,6 +110,7 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('@row-contextmenu="openShipmentContextMenu"');
expect(source).toContain("shipmentContextMenu");
expect(source).toContain('class="shipment-context-menu"');
expect(source).toContain('class="preview-actions"');
expect(source).toContain('@click="openSelectedShipment"');
expect(source).toContain('@click="openSelectedShipmentEditor"');
expect(source).toContain('@click="removeSelectedShipment"');
+41 -3
View File
@@ -164,8 +164,12 @@
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button size="small" @click="openSelectedShipment">打开详情</el-button>
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedShipment.center_id)" @click="openSelectedShipmentEditor">
<el-button
v-if="canUpdate"
size="small"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
@@ -818,12 +822,26 @@ onMounted(async () => {
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.shipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.shipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Page Header ==================== */
.create-btn {
height: 34px;
@@ -903,6 +921,16 @@ onMounted(async () => {
0 2px 8px rgba(0, 0, 0, 0.02);
}
/* 桌面端:table-card 填满、无圆角 */
.shipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.shipment-workbench {
min-width: 0;
min-height: 0;
@@ -911,7 +939,9 @@ onMounted(async () => {
.shipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
min-height: min(590px, calc(100vh - 186px));
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.shipment-table-pane {
@@ -919,6 +949,14 @@ onMounted(async () => {
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.shipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.shipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 1px solid #e3e9f1;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEtmfSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
describe("EtmfPlaceholder copy", () => {
it("does not show redundant directory count or select-node helper copy", () => {
const source = readEtmfSource();
expect(source).not.toContain("个目录节点");
expect(source).not.toContain("selectNodeHint");
});
});
describe("EtmfPlaceholder desktop layout", () => {
it("keeps archive status in the document header instead of a standalone side column", () => {
const source = readEtmfSource();
expect(source).toContain('class="document-status-summary"');
expect(source).toContain('class="status-summary-list"');
expect(source).not.toContain('class="etmf-node-detail"');
expect(source).toContain("grid-template-columns: minmax(280px, 340px) minmax(620px, 1fr);");
expect(source).not.toContain("grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);");
});
});
+380 -240
View File
@@ -5,43 +5,41 @@
<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>
<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 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>
<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 @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button v-if="canCreate" @click="openNodeDialog">
<el-icon class="el-icon--left"><FolderAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newNode }}
</el-button>
<el-button v-if="canCreate && selectedNode" type="primary" @click="openDocumentDialog">
<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 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">
@@ -49,7 +47,6 @@
<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">
@@ -86,69 +83,30 @@
<main class="etmf-document-panel">
<div class="panel-heading">
<div>
<div class="panel-heading-left">
<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>
<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 effect="plain" :type="statusType(selectedNode.status)">
<span>{{ selectedNode.effective_document_count }} 生效</span>
<el-tag size="small" 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>
<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>
@@ -166,18 +124,44 @@
<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>
<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>
@@ -522,151 +506,233 @@ 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);
/* ==================== 高度链:让工作区撑满页面剩余空间 ==================== */
/* .page 本身占满路由容器给它的全部高度 */
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* unified-shell(main-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: 14px;
gap: 10px;
width: 100%;
min-width: 0;
}
.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;
gap: 8px;
flex-shrink: 0;
}
.etmf-filter-item {
width: 240px;
min-width: 180px;
width: 180px;
min-width: 140px;
}
.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-inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
}
.etmf-status-card {
min-height: 64px;
display: grid;
align-content: center;
gap: 2px;
padding: 10px 14px;
border: 1px solid #e4eaf2;
.etmf-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 10px;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
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;
}
.status-card-value {
font-size: 22px;
/* 左侧彩色竖线 */
.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;
font-weight: 750;
color: #172033;
letter-spacing: -0.03em;
min-width: 1ch;
text-align: right;
}
.status-card-label {
font-size: 12px;
color: #68758a;
.status-badge-label {
font-size: 11px;
font-weight: 600;
color: #8a9ab0;
letter-spacing: 0.01em;
line-height: 1.2;
}
.etmf-status-card--missing .status-card-value {
color: #c24141;
}
/* 目录 — 蓝色 */
.etmf-status-badge--nodes::before { background: #4f7ecf; }
.etmf-status-badge--nodes .status-badge-value { color: #1e3a6e; }
.etmf-status-card--uploaded .status-card-value {
color: #b7791f;
/* 缺失 — 红色 */
.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-card--effective .status-card-value {
color: #24764b;
/* 已上传 — 琥珀色 */
.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: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);
min-height: calc(100vh - 260px);
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,
.etmf-node-detail {
.etmf-document-panel {
min-width: 0;
padding: 16px;
min-height: 0;
padding: 12px 14px;
overflow-y: auto;
}
.etmf-tree-panel {
border-right: 1px solid var(--ctms-border-light);
background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fc 100%);
border-right: 1px solid #e4e8ef;
background: linear-gradient(180deg, #f9fbff 0%, #f4f7fd 100%);
}
.etmf-document-panel {
border-right: 1px solid var(--ctms-border-light);
background: #fff;
overflow-x: hidden;
}
.panel-heading {
min-height: 48px;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(79, 126, 207, 0.08);
}
.panel-heading--compact {
min-height: 38px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.panel-heading-left {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
overflow: hidden;
}
.panel-title {
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.01em;
}
.panel-subtitle {
margin-top: 4px;
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 6px;
}
.etmf-tree {
--el-tree-node-hover-bg-color: #eef4ff;
--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: 34px;
border-radius: 7px;
margin: 2px 0;
height: 30px;
border-radius: 6px;
margin: 1px 0;
transition: background 0.15s ease;
}
.etmf-tree :deep(.is-current > .el-tree-node__content) {
background: #eaf2ff;
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;
}
@@ -674,16 +740,21 @@ onMounted(load);
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr) 28px max-content;
grid-template-columns: max-content minmax(0, 1fr) 22px max-content;
align-items: center;
gap: 8px;
padding-right: 8px;
gap: 6px;
padding-right: 6px;
}
.tree-node-code {
font-size: 12px;
font-weight: 700;
color: #315f9f;
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 {
@@ -691,159 +762,228 @@ onMounted(load);
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
font-size: 12px;
}
.tree-node-count {
height: 20px;
min-width: 22px;
height: 18px;
min-width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #edf2f7;
background: linear-gradient(135deg, #e8edf5, #dce4f0);
color: #526174;
font-size: 12px;
font-weight: 650;
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: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
font-size: 12px;
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%;
}
.node-meta {
display: grid;
gap: 12px;
margin: 16px 0;
/* 文档表格行悬停效果 */
.etmf-document-table :deep(.el-table__body tr:hover > td) {
background: rgba(79, 126, 207, 0.04) !important;
cursor: pointer;
}
.node-meta div {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 10px;
/* 表格头部紧凑化 */
.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;
}
.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;
.etmf-document-table :deep(td.el-table__cell) {
padding: 8px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--ctms-text-main);
}
.node-status-note--effective {
border-left-color: #2f8f5b;
/* 隐藏表格底部分隔线(el-table inner-wrapper 的 ::before 伪元素) */
.etmf-document-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.node-status-note--missing {
border-left-color: #c84646;
/* 隐藏横向滚动条 */
.etmf-document-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.node-status-note--uploaded {
border-left-color: #c58b20;
.etmf-document-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.etmf-empty-block {
min-height: 168px;
min-height: 120px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 22px;
padding: 20px;
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);
min-height: 200px;
border-radius: 10px;
background: linear-gradient(135deg, #f4f7fd 0%, #eef3fb 100%);
}
.etmf-empty-block--documents {
min-height: 420px;
}
.etmf-empty-block--detail {
min-height: 280px;
min-height: 300px;
}
.empty-icon {
width: 44px;
height: 44px;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #eef3f9;
color: #6b7c91;
font-size: 22px;
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: 15px;
font-size: 13px;
font-weight: 700;
color: #2f3a4c;
color: #2a3550;
letter-spacing: -0.01em;
}
.empty-desc {
max-width: 320px;
font-size: 13px;
line-height: 1.55;
max-width: 280px;
font-size: 12px;
line-height: 1.5;
color: #7b8797;
}
@media (max-width: 1180px) {
@media (max-width: 900px) {
.etmf-toolbar {
align-items: stretch;
flex-direction: column;
flex-wrap: wrap;
}
.etmf-toolbar-actions {
justify-content: flex-start;
.etmf-status-inline {
display: none;
}
.etmf-workspace {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-columns: 240px minmax(0, 1fr);
}
.etmf-node-detail {
grid-column: 1 / -1;
border-top: 1px solid var(--ctms-border-light);
.status-summary-list {
display: none;
}
.document-status-summary {
flex-wrap: wrap;
}
}
@media (max-width: 760px) {
.etmf-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 700px) {
.etmf-toolbar {
flex-direction: column;
align-items: stretch;
}
.etmf-toolbar-main {
flex-direction: column;
align-items: stretch;
flex-wrap: wrap;
}
.etmf-filter-item {
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
describe("MaterialEquipment desktop list workflow", () => {
it("keeps desktop equipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="equipment-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="equipment-preview-pane"');
expect(source).toContain('ref="equipmentTablePaneRef"');
expect(source).toContain("selectedEquipment");
expect(source).toContain('@row-click="handleEquipmentRowClick"');
expect(source).toContain('@row-dblclick="openEquipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedEquipment"');
expect(source).toContain("selectEquipment(row);");
expect(source).toContain("equipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain('router.push({ name: "MaterialEquipmentDetail"');
expect(source).toContain('"row-selected"');
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop equipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openEquipmentContextMenu"');
expect(source).toContain("equipmentContextMenu");
expect(source).toContain('class="equipment-context-menu"');
expect(source).toContain('@click="openSelectedEquipment"');
expect(source).toContain('@click="openSelectedEquipmentEditor"');
expect(source).toContain('@click="removeSelectedEquipment"');
expect(source).toContain('@click="copySelectedEquipmentName"');
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
});
});
+474 -80
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'equipment-page--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片(含筛选栏) ==================== -->
<div class="table-card">
@@ -24,59 +24,118 @@
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
<div class="equipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="equipmentTablePaneRef"
class="equipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedEquipment"
>
<el-table
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="handleEquipmentRowClick"
@row-dblclick="openEquipmentDetail"
@row-contextmenu="openEquipmentContextMenu"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>暂无设备数据</span>
</div>
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="equipment-preview-pane">
<template v-if="selectedEquipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前设备</div>
<div class="preview-title">{{ selectedEquipment.name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedEquipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>规格型号</dt>
<dd>{{ selectedEquipment.specModel || TEXT.common.fallback }}</dd>
<dt>单位</dt>
<dd>{{ selectedEquipment.unit || TEXT.common.fallback }}</dd>
<dt>品牌</dt>
<dd>{{ selectedEquipment.brand || TEXT.common.fallback }}</dd>
<dt>产地</dt>
<dd>{{ selectedEquipment.origin || TEXT.common.fallback }}</dd>
<dt>校准</dt>
<dd>{{ selectedEquipment.needCalibration ? "需要校准" : "无需校准" }}</dd>
<dt>周期</dt>
<dd>{{ selectedEquipment.needCalibration ? `${selectedEquipment.calibrationCycleDays || TEXT.common.fallback} 天` : TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button v-if="canUpdate" size="small" @click="openSelectedEquipmentEditor">编辑</el-button>
<el-button v-if="canDelete" size="small" type="danger" plain @click="removeSelectedEquipment">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div v-else class="preview-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
</div>
<span>暂无设备数据</span>
<span>选择一行查看详情摘要</span>
</div>
</template>
</el-table>
</aside>
</div>
</div>
<div
v-if="isDesktop && equipmentContextMenu.visible && selectedEquipment"
class="equipment-context-menu"
:style="{ left: `${equipmentContextMenu.x}px`, top: `${equipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedEquipment">打开详情</button>
<button v-if="canUpdate" type="button" @click="openSelectedEquipmentEditor">编辑</button>
<button type="button" @click="copySelectedEquipmentName">复制设备名称</button>
<button v-if="canDelete" type="button" class="danger" @click="removeSelectedEquipment">删除</button>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -174,7 +233,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
@@ -192,6 +251,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
interface EquipmentRow {
id: string;
@@ -209,12 +269,16 @@ type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const isDesktop = isTauriRuntime();
const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]);
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const editingId = ref("");
const selectedEquipmentId = ref("");
const equipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const equipmentTablePaneRef = ref<HTMLElement | null>(null);
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
@@ -239,6 +303,7 @@ const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.c
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
const selectedEquipment = computed(() => rows.value.find((item) => item.id === selectedEquipmentId.value) || null);
const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -326,11 +391,76 @@ const openEdit = (row: EquipmentRow) => {
drawerVisible.value = true;
};
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : "");
const equipmentRowClass = ({ row }: { row: EquipmentRow }) =>
[
row?.id ? "clickable-row" : "",
isDesktop && row?.id === selectedEquipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const onRowClick = (row: EquipmentRow) => {
const selectEquipment = (row: EquipmentRow) => {
if (!row?.id) return;
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } });
closeEquipmentContextMenu();
selectedEquipmentId.value = row.id;
equipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleEquipmentRowClick = (row: EquipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectEquipment(row);
return;
}
openEquipmentDetail(row);
};
const openEquipmentDetail = (row?: EquipmentRow | null) => {
const target = row?.id ? row : selectedEquipment.value;
if (target?.id) router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: target.id } });
};
const openSelectedEquipment = () => {
closeEquipmentContextMenu();
openEquipmentDetail(selectedEquipment.value);
};
const openSelectedEquipmentEditor = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedEquipment = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) {
void removeRow(target);
}
};
const openEquipmentContextMenu = (row: EquipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectEquipment(row);
equipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 172)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 142)),
};
};
const closeEquipmentContextMenu = () => {
if (!equipmentContextMenu.value.visible) return;
equipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedEquipmentName = async () => {
const name = selectedEquipment.value?.name;
closeEquipmentContextMenu();
if (!name || !navigator.clipboard) return;
await navigator.clipboard.writeText(name);
ElMessage.success("设备名称已复制");
};
const saveForm = async () => {
@@ -405,10 +535,25 @@ watch(
filters.name = "";
loadRows();
drawerVisible.value = false;
selectedEquipmentId.value = "";
},
{ immediate: true }
);
watch(
() => rows.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
if (!rows.value.length) {
selectedEquipmentId.value = "";
return;
}
if (!rows.value.some((item) => item.id === selectedEquipmentId.value)) {
selectedEquipmentId.value = rows.value[0].id;
}
},
);
watch(
() => form.needCalibration,
(need) => {
@@ -416,6 +561,14 @@ watch(
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
}
);
onMounted(() => {
if (isDesktop) document.addEventListener("click", closeEquipmentContextMenu);
});
onBeforeUnmount(() => {
if (isDesktop) document.removeEventListener("click", closeEquipmentContextMenu);
});
</script>
<style scoped>
@@ -427,20 +580,35 @@ watch(
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.equipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.equipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Table Toolbar ==================== */
.table-card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
gap: 12px;
background: linear-gradient(135deg, #f8faff 0%, #f2f6ff 100%);
}
.toolbar-filters {
@@ -466,16 +634,30 @@ watch(
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
font-weight: 700;
color: #7a8ca8;
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.04em;
}
.filter-input {
width: 200px;
}
.filter-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(79, 126, 207, 0.2);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition: border-color 0.2s, box-shadow 0.2s;
}
.filter-input :deep(.el-input__wrapper:hover),
.filter-input :deep(.el-input__wrapper.is-focus) {
border-color: rgba(79, 126, 207, 0.5);
box-shadow: 0 0 0 3px rgba(79, 126, 207, 0.1);
}
.filter-actions {
display: flex;
gap: 8px;
@@ -484,21 +666,35 @@ watch(
}
.filter-btn {
border-radius: 6px;
border-radius: 8px;
}
.filter-summary {
display: inline-flex;
align-items: center;
padding: 2px 10px;
background: rgba(79, 126, 207, 0.08);
border: 1px solid rgba(79, 126, 207, 0.15);
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
font-weight: 700;
color: #4f7ecf;
white-space: nowrap;
letter-spacing: 0.01em;
}
.create-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
padding: 0 18px;
font-weight: 600;
box-shadow: 0 2px 6px rgba(64, 128, 220, 0.25);
transition: box-shadow 0.2s, transform 0.15s;
}
.create-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 128, 220, 0.35);
}
/* ==================== Table Card ==================== */
@@ -511,6 +707,58 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02);
}
/* 桌面端:table-card 填满、无圆角 */
.equipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.equipment-workbench {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.equipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 304px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.equipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.equipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.equipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.equipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 14px;
padding: 16px;
background: linear-gradient(180deg, #f5f8ff 0%, #eef3fb 100%);
border-left: 1px solid rgba(79, 126, 207, 0.1);
}
.equipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -543,6 +791,10 @@ watch(
transition: background 0.1s ease;
}
.equipment-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */
.cell-nowrap {
white-space: nowrap;
@@ -573,21 +825,151 @@ watch(
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 12px;
padding: 2px 10px;
font-size: 11px;
font-weight: 700;
border-radius: 20px;
line-height: 1.6;
line-height: 1.7;
letter-spacing: 0.02em;
}
.status-pill--yes {
background: #dcfce7;
color: #16a34a;
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46;
border: 1px solid rgba(16, 185, 129, 0.2);
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.15);
}
.status-pill--no {
background: #f5f5f5;
color: #737373;
background: #f1f5f9;
color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.15);
}
/* ==================== Desktop Preview ==================== */
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
}
.preview-kicker {
display: inline-flex;
align-items: center;
gap: 5px;
color: #4f7ecf;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(79, 126, 207, 0.1);
padding: 2px 8px;
border-radius: 20px;
}
.preview-title {
margin-top: 8px;
color: #0f172a;
font-size: 17px;
font-weight: 800;
word-break: break-word;
letter-spacing: -0.01em;
line-height: 1.3;
}
.preview-list {
display: block;
margin: 0;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 10px;
overflow: hidden;
background: #fff;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);
}
/* dt/dd float 布局:dt 左浮动作为标签列,dd 跟随在右侧 */
.preview-list dt {
float: left;
clear: left;
width: 64px;
padding: 9px 6px 9px 14px;
color: #7a8ca8;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.5;
}
.preview-list dd {
margin-left: 0;
padding: 9px 14px 9px 78px;
border-bottom: 1px solid rgba(79, 126, 207, 0.07);
color: #1a2540;
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
min-width: 0;
}
.preview-list dd:last-child {
border-bottom: none;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding-top: 4px;
}
.preview-empty {
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.equipment-context-menu {
position: fixed;
z-index: 3000;
min-width: 152px;
padding: 5px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.equipment-context-menu button {
display: block;
width: 100%;
min-height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.equipment-context-menu button:hover:not(:disabled) {
background: #eef4ff;
}
.equipment-context-menu button.danger {
color: #dc2626;
}
/* ==================== Empty State ==================== */
@@ -597,30 +979,33 @@ watch(
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 12px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
box-shadow: 0 4px 12px rgba(79, 126, 207, 0.15);
}
.table-empty span {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
font-weight: 600;
color: #8aa0bd;
}
/* ==================== Drawer Editor ==================== */
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 20px 24px 8px;
padding: 20px 24px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
}
:deep(.equipment-editor-drawer > .el-drawer__body) {
@@ -634,10 +1019,10 @@ watch(
.editor-title {
font-size: 18px;
font-weight: 700;
font-weight: 800;
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
letter-spacing: -0.02em;
}
.equipment-form {
@@ -746,6 +1131,15 @@ watch(
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.equipment-workbench.is-desktop {
grid-template-columns: 1fr;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 0;
}
.equipment-preview-pane {
display: none;
}
.field-grid--2,
.field-grid--3 {
grid-template-columns: 1fr;
@@ -462,6 +462,32 @@ watch(
.page {
display: flex;
flex-direction: column;
/* 撑满父容器(desktop-route-shell 已经是 height: 100%)*/
height: 100%;
min-height: 0;
}
/* 让白色卡片壳撑满 .page 的剩余高度,并去掉圆角 */
.page > .unified-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
}
/* 让表格 section 撑满卡片壳的剩余高度 */
.page > .unified-shell > .unified-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* el-table 本身填满 section */
.page > .unified-shell > .unified-section :deep(.el-table),
.page > .unified-shell > .unified-section :deep(.el-table__inner-wrapper) {
height: 100%;
}
.ctms-page-content-grid {
@@ -689,4 +715,5 @@ watch(
font-weight: 500;
letter-spacing: 0.02em;
}
</style>
+652 -58
View File
@@ -1,72 +1,147 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="page ctms-page-shell page--flush" :class="{ 'project-overview--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-body">
<div class="overview-container">
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
<section v-if="isDesktop" class="desktop-attention-section">
<div class="desktop-attention-head">
<span>项目关注</span>
<span>更新 {{ overviewUpdatedAtLabel }}</span>
</div>
<div class="desktop-attention-board">
<div class="attention-card attention-card--stages">
<div class="attention-title">阶段状态</div>
<div class="stage-status-grid">
<div v-for="item in stageStatusSummary" :key="item.key" class="stage-status-item">
<span class="legend-dot" :class="item.dotClass"></span>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</div>
</div>
</div>
</div>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div class="attention-card">
<div class="attention-title">当前推进</div>
<div v-if="activeStageItems.length" class="attention-list">
<div v-for="item in activeStageItems" :key="item.key" class="attention-list-row">
<strong>{{ item.centerName }}</strong>
<span>{{ item.stageLabel }}</span>
</div>
</div>
<div v-else class="desktop-empty-note">暂无进行中阶段</div>
</div>
<div class="attention-card">
<div class="attention-title">需关注</div>
<div class="attention-list">
<div v-for="item in attentionItems" :key="item" class="attention-list-row attention-list-row--plain">
<span>{{ item }}</span>
</div>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心,或尚未形成可展示的中心进度数据。</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
<div class="overview-workbench">
<section class="overview-card overview-card--progress">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
</div>
</div>
</div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
<EnrollmentBarChart
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心,或尚未形成可展示的中心进度数据。</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card overview-card--enrollment">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
</div>
</div>
<el-radio-group v-if="!isDesktop" v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
<StateLoading v-if="isDesktop && loading" :rows="4" />
<div v-else-if="isDesktop" class="enrollment-snapshot">
<div class="enrollment-meter">
<div class="meter-head">
<span>达成率</span>
<strong>{{ enrollmentCompletionLabel }}</strong>
</div>
<div class="meter-track">
<span class="meter-fill" :style="{ width: enrollmentCompletionWidth }"></span>
</div>
<div class="meter-foot">
<span>已入组 {{ overview?.summary.total_actual || 0 }}</span>
<span>目标 {{ overview?.summary.total_target || 0 }}</span>
</div>
</div>
<div class="enrollment-center-list">
<div v-for="row in enrollmentCenterRows" :key="row.key" class="enrollment-center-row">
<div class="enrollment-center-meta">
<span>{{ row.label }}</span>
<strong>{{ row.actual }} / {{ row.target }}</strong>
</div>
<div class="mini-progress-track">
<span class="mini-progress-fill" :style="{ width: row.percentWidth }"></span>
</div>
</div>
<div v-if="enrollmentCenterRows.length === 0" class="desktop-empty-note">
暂无中心入组数据
</div>
</div>
</div>
<EnrollmentBarChart
v-else
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
</div>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -80,18 +155,127 @@ import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites";
import { isTauriRuntime } from "../../runtime";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
import { STAGE_ORDER, adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel, type StageStatus } from "./project-overview/overview.adapter";
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return 0;
return Math.min(100, Math.round((summary.total_actual / summary.total_target) * 100));
});
const enrollmentCompletionLabel = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return "未设目标";
return `${enrollmentCompletionRate.value}%`;
});
const enrollmentCompletionWidth = computed(() => `${enrollmentCompletionRate.value}%`);
const overviewUpdatedAtLabel = computed(() => {
if (!overview.value?.updated_at) return "-";
const date = new Date(overview.value.updated_at);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
};
const statusDotClassMap: Record<StageStatus, string> = {
COMPLETED: "completed",
IN_PROGRESS: "active",
NOT_STARTED: "pending",
BLOCKED: "blocked",
};
const stageStatusCounts = computed<Record<StageStatus, number>>(() => {
const counts: Record<StageStatus, number> = {
COMPLETED: 0,
IN_PROGRESS: 0,
NOT_STARTED: 0,
BLOCKED: 0,
};
centers.value.forEach((center) => {
STAGE_ORDER.forEach((stage) => {
counts[center[stage.key] || "NOT_STARTED"] += 1;
});
});
return counts;
});
const stageStatusSummary = computed(() =>
(["COMPLETED", "IN_PROGRESS", "NOT_STARTED", "BLOCKED"] as StageStatus[]).map((status) => ({
key: status,
label: statusLabelMap[status],
count: stageStatusCounts.value[status],
dotClass: statusDotClassMap[status],
}))
);
const activeStageItems = computed(() =>
centers.value
.flatMap((center, centerIndex) =>
STAGE_ORDER
.filter((stage) => center[stage.key] === "IN_PROGRESS")
.map((stage) => ({
key: `${center.center_id || centerIndex}-${stage.key}`,
centerName: center.center_name || TEXT.common.fallback,
stageLabel: stage.label,
}))
)
.slice(0, 4)
);
const attentionItems = computed(() => {
const items: string[] = [];
const targetlessCenters = centers.value.filter((center) => !center.enrollment_target).length;
const inactiveCenters = centers.value.filter((center) => center.is_active === false).length;
if (stageStatusCounts.value.BLOCKED > 0) {
items.push(`${stageStatusCounts.value.BLOCKED} 个阶段阻塞/延期`);
}
if (targetlessCenters > 0) {
items.push(`${targetlessCenters} 个中心未设置入组目标`);
}
if (overview.value && overview.value.months.length === 0) {
items.push("暂无月度入组趋势数据");
}
if (inactiveCenters > 0) {
items.push(`${inactiveCenters} 个中心已停用`);
}
if (items.length === 0) {
items.push("暂无需要额外关注的总览风险");
}
return items.slice(0, 4);
});
const enrollmentCenterRows = computed(() =>
centers.value.slice(0, 4).map((center, index) => {
const target = center.enrollment_target || 0;
const actual = center.enrollment_actual || 0;
const percent = target > 0 ? Math.min(100, Math.round((actual / target) * 100)) : 0;
return {
key: center.center_id || center.center_name || `center-${index}`,
label: center.center_name || TEXT.common.fallback,
actual,
target,
percentWidth: `${percent}%`,
};
})
);
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
siteList.map((site: any) => ({
@@ -218,6 +402,12 @@ watch(
gap: 10px;
}
.overview-workbench {
display: flex;
flex-direction: column;
gap: 10px;
}
.card-header-right {
display: flex;
align-items: center;
@@ -389,6 +579,401 @@ watch(
border-radius: 8px;
}
.project-overview--desktop {
min-height: 0;
}
.project-overview--desktop .page-body {
min-height: 0;
gap: 8px;
}
.project-overview--desktop .overview-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
gap: 10px;
}
.project-overview--desktop .overview-workbench {
display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);
align-items: stretch;
overflow: hidden;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.project-overview--desktop .overview-card {
min-width: 0;
border: 0;
border-radius: 0;
padding: 12px 14px;
background: transparent;
box-shadow: none;
}
.project-overview--desktop .overview-card:hover {
box-shadow: none;
}
.project-overview--desktop .overview-card--enrollment {
border-left: 1px solid #d9e2ec;
}
.project-overview--desktop .card-header {
margin-bottom: 8px;
}
.project-overview--desktop .card-header-left {
gap: 8px;
}
.project-overview--desktop .card-icon {
width: 24px;
height: 24px;
border-radius: 5px;
}
.project-overview--desktop .card-title {
font-size: 13px;
}
.project-overview--desktop .card-subtitle,
.project-overview--desktop .progress-legend {
font-size: 11px;
}
.project-overview--desktop .progress-legend {
gap: 8px;
}
.project-overview--desktop .legend-dot {
width: 8px;
height: 8px;
}
.enrollment-snapshot {
display: flex;
flex-direction: column;
gap: 14px;
}
.enrollment-meter {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 6px;
background: #f8fafc;
}
.meter-head,
.meter-foot,
.enrollment-center-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.meter-head span,
.meter-foot,
.enrollment-center-meta span {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.meter-head strong {
color: #15344f;
font-size: 18px;
line-height: 1;
}
.meter-track,
.mini-progress-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: #e8eef5;
}
.meter-fill,
.mini-progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: #3f5d75;
}
.enrollment-center-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.enrollment-center-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.enrollment-center-meta strong {
color: #1f2f45;
font-size: 12px;
}
.project-overview--desktop .progress-list {
max-height: min(360px, calc(100vh - 300px));
overflow: auto;
padding-right: 2px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.project-overview--desktop .progress-list::-webkit-scrollbar {
width: 8px;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-track {
background: transparent;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-thumb {
border: 2px solid #ffffff;
border-radius: 999px;
background: #c8d4e1;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: minmax(126px, 156px) minmax(0, 1fr);
gap: 8px;
padding: 6px;
border-radius: 5px;
}
.project-overview--desktop :deep(.center-name) {
font-size: 13px;
}
.project-overview--desktop :deep(.center-enrollment) {
font-size: 11px;
}
.project-overview--desktop :deep(.center-timeline) {
padding: 4px 0 0;
}
.project-overview--desktop :deep(.timeline-segment) {
gap: 6px;
}
.project-overview--desktop :deep(.stage-node) {
min-width: 52px;
gap: 4px;
}
.project-overview--desktop :deep(.stage-dot) {
width: 13px;
height: 13px;
}
.project-overview--desktop :deep(.stage-in_progress .stage-dot) {
width: 16px;
height: 16px;
}
.project-overview--desktop :deep(.stage-label) {
max-width: 62px;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
.project-overview--desktop :deep(.stage-in_progress .stage-label) {
padding: 1px 6px;
}
.project-overview--desktop :deep(.stage-connector) {
min-width: 30px;
}
.project-overview--desktop :deep(.chart-scroll) {
overflow-x: auto;
overflow-y: hidden;
}
.project-overview--desktop :deep(.chart-plot) {
border-radius: 6px;
background: #f8fafc;
}
.project-overview--desktop :deep(.chart-empty-shell) {
min-height: 112px;
border-radius: 6px;
padding: 14px;
}
.project-overview--desktop .overview-empty-panel {
min-height: 104px;
padding: 12px;
border-radius: 6px;
}
.desktop-attention-board {
display: grid;
grid-template-columns: minmax(220px, 0.75fr) minmax(260px, 1fr) minmax(260px, 1fr);
gap: 10px;
}
.desktop-attention-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.desktop-attention-head {
display: flex;
min-height: 18px;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.attention-card {
min-width: 0;
min-height: 126px;
padding: 12px 14px;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.attention-title {
margin-bottom: 10px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.stage-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.stage-status-item,
.attention-list-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 8px;
border-radius: 6px;
background: #f8fafc;
}
.stage-status-item span:not(.legend-dot),
.attention-list-row span {
min-width: 0;
overflow: hidden;
color: #53677f;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-status-item strong {
margin-left: auto;
color: #15253a;
font-size: 13px;
}
.attention-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attention-list-row strong {
min-width: 0;
overflow: hidden;
color: #15253a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.attention-list-row--plain {
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
}
.attention-list-row--plain span {
white-space: normal;
}
.desktop-empty-note {
min-height: 32px;
display: flex;
align-items: center;
color: #7b8da3;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-workbench),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-card) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-card--enrollment) {
border-left-color: #26364a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .desktop-attention-head) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-meter),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) {
background: #111a2a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-title),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-center-meta strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .meter-head strong) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .progress-list::-webkit-scrollbar-thumb) {
border-color: #172033;
background: #3b4b60;
}
@media (max-width: 1240px) {
.project-overview--desktop .overview-workbench,
.desktop-attention-board {
grid-template-columns: 1fr;
}
.project-overview--desktop .overview-card--enrollment {
border-top: 1px solid #d9e2ec;
border-left: 0;
}
}
@media (max-width: 768px) {
.overview-container {
padding: 8px;
@@ -406,5 +991,14 @@ watch(
.card-header-right {
justify-content: flex-start;
}
.project-overview--desktop .overview-container {
grid-template-columns: 1fr;
padding-top: 8px;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: 1fr;
}
}
</style>
@@ -5,15 +5,17 @@ import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
describe("SubjectManagement project permissions", () => {
it("hides create and delete actions when the current role lacks backend operation permissions", () => {
it("hides create, edit, and delete actions when the current role lacks backend operation permissions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:update")');
expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canUpdateSubject"');
expect(source).toContain('v-if="canDeleteSubject"');
});
});
@@ -24,6 +26,8 @@ describe("SubjectManagement drawer editor", () => {
expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible");
expect(source).toContain("editingSubjectId");
expect(source).toContain(':subject-id="editingSubjectId || undefined"');
expect(source).not.toContain('router.push("/subjects/new")');
});
});
@@ -32,10 +36,49 @@ describe("SubjectManagement desktop list workflow", () => {
it("selects rows for preview and opens details on explicit desktop actions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("@row-click=\"selectSubject\"");
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="subject-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="subject-preview-pane"');
expect(source).toContain('ref="subjectTablePaneRef"');
expect(source).toContain("selectedSubject");
expect(source).toContain("@row-click=\"handleSubjectRowClick\"");
expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
expect(source).toContain("@keydown.enter.prevent=\"openSelectedSubject\"");
expect(source).toContain("selectSubject(row);");
expect(source).toContain("subjectTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain("subject-preview-pane");
expect(source).toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedSubjectEditor\"");
expect(source).toContain("@click=\"removeSelectedSubject\"");
expect(source).not.toContain('<el-button size="small" @click="copySelectedSubjectNo">复制编号</el-button>');
expect(source).toContain("subject-context-menu");
expect(source).toContain('v-if="!isDesktop && canDeleteSubject"');
expect(source).toContain('"row-selected"');
});
it("does not render unused table selection controls", () => {
const source = readSubjectManagementSource();
expect(source).not.toContain('type="selection"');
expect(source).not.toContain("@selection-change");
expect(source).not.toContain("selectedRows");
expect(source).not.toContain("selection-count");
});
it("keeps desktop footer controls at the bottom of the workbench", () => {
const source = readSubjectManagementSource();
expect(source).toContain("subject-page--desktop");
expect(source).toContain(".subject-page--desktop .table-card");
expect(source).toContain(".subject-workbench.is-desktop");
expect(source).toContain(".subject-table-pane");
expect(source).toContain(".pagination-wrap");
expect(source).toContain(".preview-actions");
expect(source.match(/margin-top: auto;/g)?.length).toBeGreaterThanOrEqual(2);
});
});
+132 -38
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'subject-page--desktop': isDesktop }">
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
@@ -24,7 +24,6 @@
</div>
</div>
<div class="toolbar-right">
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} 项</span>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -32,8 +31,13 @@
</div>
</div>
<div class="subject-workbench">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
<div class="subject-workbench" :class="{ 'is-desktop': isDesktop }">
<div
class="subject-table-pane"
ref="subjectTablePaneRef"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedSubject"
>
<el-table
:data="pagedItems"
v-loading="loading"
@@ -41,13 +45,11 @@
class="subject-table"
:row-class-name="subjectRowClass"
highlight-current-row
@selection-change="onSelectionChange"
@row-click="selectSubject"
@row-click="handleSubjectRowClick"
@row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu"
table-layout="fixed"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
@@ -73,7 +75,7 @@
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<el-table-column v-if="!isDesktop && canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
@@ -104,7 +106,7 @@
</div>
</div>
<aside class="subject-preview-pane">
<aside v-if="isDesktop" class="subject-preview-pane">
<template v-if="selectedSubject">
<div class="preview-head">
<div>
@@ -130,6 +132,26 @@
{{ badge.label }}
</span>
</div>
<div class="preview-actions">
<el-button
v-if="canUpdateSubject"
size="small"
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="openSelectedSubjectEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteSubject"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="removeSelectedSubject"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<div class="empty-icon">
@@ -142,7 +164,7 @@
</div>
<div
v-if="contextMenu.visible"
v-if="isDesktop && contextMenu.visible && selectedSubject"
class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
@@ -154,13 +176,17 @@
type="button"
class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)"
@click="removeSelectedSubject"
>
删除
</button>
</div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
<SubjectEditorDrawer
v-model="subjectDrawerVisible"
:subject-id="editingSubjectId || undefined"
@success="handleSubjectEditorSuccess"
/>
</div>
</template>
@@ -179,15 +205,18 @@ import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const editingSubjectId = ref("");
const subjectTablePaneRef = ref<HTMLElement | null>(null);
const items = ref<any[]>([]);
const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
@@ -215,6 +244,7 @@ const canUseApiPermission = (operationKey: string) => {
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
};
const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => {
const studyId = study.currentStudy?.id;
@@ -251,14 +281,26 @@ const load = async () => {
}
};
const goNew = () => { subjectDrawerVisible.value = true; };
const goNew = () => {
editingSubjectId.value = "";
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { load(); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => {
contextMenu.value.visible = false;
closeSubjectContextMenu();
if (!row?.id) return;
selectedSubjectId.value = row.id;
subjectTablePaneRef.value?.focus({ preventScroll: true });
};
const handleSubjectRowClick = (row: any) => {
if (!row?.id) return;
if (isDesktop) {
selectSubject(row);
return;
}
goDetail(row.id);
};
const openSubjectDetail = (row: any) => {
if (!row?.id) return;
@@ -268,21 +310,19 @@ const openSelectedSubject = () => {
contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
};
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
if (!row?.id) return;
selectedSubjectId.value = row.id;
selectSubject(row);
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 152)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 122)),
};
};
const closeSubjectContextMenu = () => {
contextMenu.value.visible = false;
if (!contextMenu.value.visible) return;
contextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false;
@@ -291,6 +331,28 @@ const copySelectedSubjectNo = async () => {
await navigator.clipboard?.writeText(subjectNo);
ElMessage.success("编号已复制");
};
const openSelectedSubjectEditor = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (!target) return;
if (!canUpdateSubject.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(target.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingSubjectId.value = target.id;
subjectDrawerVisible.value = true;
};
const removeSelectedSubject = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (target) {
void remove(target);
}
};
const remove = async (row: any) => {
contextMenu.value.visible = false;
const studyId = study.currentStudy?.id;
@@ -349,6 +411,7 @@ watch(() => filteredItems.value.length, (total) => {
});
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => {
if (!isDesktop) return;
if (!rows.length) {
selectedSubjectId.value = "";
return;
@@ -359,7 +422,7 @@ watch(pagedItems, (rows) => {
});
onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites();
load();
@@ -369,7 +432,7 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.();
});
</script>
@@ -383,25 +446,53 @@ onBeforeUnmount(() => {
background: #ffffff;
}
.subject-page--desktop {
height: 100%;
min-height: 0;
}
.table-card {
background: #ffffff;
overflow: hidden;
}
.subject-page--desktop .table-card {
flex: 1;
display: flex;
min-height: 0;
flex-direction: column;
}
.subject-workbench {
min-width: 0;
min-height: 0;
}
.subject-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px;
flex: 1;
min-height: 0;
}
.subject-table-pane {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #edf1f7;
min-height: 0;
outline: none;
}
.subject-workbench.is-desktop .subject-table-pane {
border-right: 1px solid #edf1f7;
}
.subject-preview-pane {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
padding: 16px;
background: #fbfcff;
}
@@ -431,13 +522,6 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item {
display: flex;
flex-direction: column;
@@ -473,6 +557,8 @@ onBeforeUnmount(() => {
/* ==================== Table ==================== */
.subject-table {
flex: 1;
min-height: 0;
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
@@ -546,7 +632,7 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px;
margin: 16px 0 0;
margin: 0;
}
.preview-list dt {
@@ -566,10 +652,17 @@ onBeforeUnmount(() => {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
flex: 1;
min-height: 360px;
display: flex;
flex-direction: column;
@@ -651,6 +744,7 @@ onBeforeUnmount(() => {
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: auto;
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
@@ -680,8 +774,8 @@ onBeforeUnmount(() => {
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; }
.subject-workbench.is-desktop { grid-template-columns: 1fr; }
.subject-workbench.is-desktop .subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; }
@@ -1,5 +1,5 @@
<template>
<div class="enrollment-chart">
<div class="enrollment-chart" :class="{ 'enrollment-chart--compact': compact }">
<StateLoading v-if="loading" :rows="5" />
<div v-else-if="items.length === 0" class="chart-empty-shell">
<div class="chart-empty-body">
@@ -9,7 +9,7 @@
</div>
<div v-else class="chart-body">
<div class="chart-scroll">
<div class="chart-plot">
<div class="chart-plot" :style="chartPlotStyle">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
@@ -105,23 +105,40 @@ const props = withDefaults(
items: EnrollmentBarItem[];
loading?: boolean;
emptyText?: string;
compact?: boolean;
}>(),
{
loading: false,
emptyText: "暂无入组数据",
compact: false,
}
);
const compact = computed(() => props.compact);
const showTarget = computed(() => props.mode === "center");
const chartWidth = 960;
const chartHeight = 260;
const axisPadding = {
top: 28,
right: 32,
bottom: 48,
left: 56,
};
const baseChartWidth = computed(() => (compact.value ? 560 : 960));
const chartHeight = computed(() => (compact.value ? 240 : 260));
const axisPadding = computed(() => ({
top: compact.value ? 24 : 28,
right: compact.value ? 24 : 32,
bottom: compact.value ? 42 : 48,
left: compact.value ? 46 : 56,
}));
const chartWidth = computed(() => {
if (!compact.value) return baseChartWidth.value;
const itemWidth = showTarget.value ? 72 : 64;
return Math.max(
baseChartWidth.value,
props.items.length * itemWidth + axisPadding.value.left + axisPadding.value.right,
);
});
const chartPlotStyle = computed(() => ({
"--chart-min-width": `${chartWidth.value}px`,
"--chart-aspect": `${chartWidth.value} / ${chartHeight.value}`,
}));
const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
@@ -146,36 +163,37 @@ const yTicks = computed(() => {
});
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom);
const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 20;
const valueGap = 10;
const barRadius = 6;
const plotWidth = computed(() => chartWidth.value - axisPadding.value.left - axisPadding.value.right);
const plotHeight = computed(() => chartHeight.value - axisPadding.value.top - axisPadding.value.bottom);
const axisLeft = computed(() => axisPadding.value.left);
const axisRight = computed(() => chartWidth.value - axisPadding.value.right);
const axisTop = computed(() => axisPadding.value.top);
const axisBottom = computed(() => chartHeight.value - axisPadding.value.bottom);
const labelY = computed(() => axisBottom.value + (compact.value ? 18 : 20));
const valueGap = computed(() => (compact.value ? 8 : 10));
const barRadius = computed(() => (compact.value ? 5 : 6));
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(52, bandWidth.value * 0.5));
const barWidth = computed(() => Math.min(compact.value ? 44 : 52, bandWidth.value * 0.5));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2;
const barCenter = (index: number) => axisLeft.value + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => {
if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value;
};
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const barTop = (value: number) => axisTop.value + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop.value + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
return barTop(anchor) - valueGap.value;
};
const truncateLabel = (label: string) => {
if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`;
const limit = compact.value ? 7 : 8;
if (label.length <= limit) return label;
return `${label.slice(0, limit - 1)}...`;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
@@ -228,7 +246,8 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
border-radius: 10px;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative;
aspect-ratio: 960 / 260;
width: 100%;
aspect-ratio: var(--chart-aspect);
min-height: 200px;
}
@@ -297,6 +316,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
font-weight: 500;
}
.enrollment-chart--compact .chart-plot {
width: max(100%, var(--chart-min-width));
min-height: 0;
border-radius: 6px;
}
.enrollment-chart--compact .chart-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.enrollment-chart--compact .tick-label {
font-size: 10px;
}
.enrollment-chart--compact .bar-value {
font-size: 11px;
}
.enrollment-chart--compact .bar-value-actual {
font-size: 12px;
}
.enrollment-chart--compact .bar-label {
font-size: 10px;
}
@media (max-width: 768px) {
.bar-label {
font-size: 11px;