release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -85,4 +85,37 @@ describe("DrugShipments project permissions", () => {
|
||||
expect(source).toContain('status === "EXCEPTION"');
|
||||
expect(source).not.toContain('remark: [{ required: true');
|
||||
});
|
||||
|
||||
it("keeps desktop shipment browsing in a master detail workflow", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime();");
|
||||
expect(source).toContain('class="shipment-workbench"');
|
||||
expect(source).toContain('class="shipment-preview-pane"');
|
||||
expect(source).toContain('ref="shipmentTablePaneRef"');
|
||||
expect(source).toContain("selectedShipment");
|
||||
expect(source).toContain('@row-click="handleShipmentRowClick"');
|
||||
expect(source).toContain('@row-dblclick="openShipmentDetail"');
|
||||
expect(source).toContain('@keydown.enter.prevent="openSelectedShipment"');
|
||||
expect(source).toContain("selectShipment(row);");
|
||||
expect(source).toContain("shipmentTablePaneRef.value?.focus({ preventScroll: true });");
|
||||
expect(source).toContain("return;");
|
||||
expect(source).toContain("router.push(`/drug/shipments/${row.id}`);");
|
||||
expect(source).not.toContain('@row-click="onRowClick"');
|
||||
});
|
||||
|
||||
it("routes desktop shipment actions through preview and context menu controls", () => {
|
||||
const source = readSource();
|
||||
|
||||
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"');
|
||||
expect(source).toContain(':disabled="isInactiveSite(selectedShipment.center_id)"');
|
||||
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
|
||||
expect(source).toContain('"row-selected"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
|
||||
<div v-if="study.currentStudy" class="page-inner">
|
||||
<!-- ==================== 表格卡片(含筛选栏) ==================== -->
|
||||
<div class="table-card">
|
||||
@@ -47,85 +47,178 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="sortedItems"
|
||||
class="shipment-table"
|
||||
style="width: 100%"
|
||||
table-layout="fixed"
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
|
||||
<template #default="{ row }">
|
||||
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<div class="shipment-workbench" :class="{ 'is-desktop': isDesktop }">
|
||||
<div
|
||||
ref="shipmentTablePaneRef"
|
||||
class="shipment-table-pane"
|
||||
:tabindex="isDesktop ? 0 : undefined"
|
||||
@keydown.enter.prevent="openSelectedShipment"
|
||||
>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="sortedItems"
|
||||
class="shipment-table"
|
||||
style="width: 100%"
|
||||
table-layout="fixed"
|
||||
highlight-current-row
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="handleShipmentRowClick"
|
||||
@row-dblclick="openShipmentDetail"
|
||||
@row-contextmenu="openShipmentContextMenu"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
|
||||
<template #default="{ row }">
|
||||
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
v-if="canDelete"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(row.center_id)"
|
||||
@click.stop="remove(row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</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"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<span>{{ TEXT.modules.drugShipments.empty }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<aside v-if="isDesktop" class="shipment-preview-pane">
|
||||
<template v-if="selectedShipment">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<div class="preview-kicker">当前发运</div>
|
||||
<div class="preview-title">{{ selectedShipment.site_name || TEXT.common.fallback }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="openSelectedShipment">打开详情</el-button>
|
||||
</div>
|
||||
<dl class="preview-list">
|
||||
<dt>{{ TEXT.common.fields.direction }}</dt>
|
||||
<dd>{{ displayEnum(TEXT.enums.shipmentDirection, selectedShipment.direction) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.status }}</dt>
|
||||
<dd>{{ displayEnum(TEXT.enums.shipmentStatus, selectedShipment.status) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.shipDate }}</dt>
|
||||
<dd>{{ displayDate(selectedShipment.ship_date) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.receiveDate }}</dt>
|
||||
<dd>{{ displayDate(selectedShipment.receive_date) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.quantity }}</dt>
|
||||
<dd>{{ typeof selectedShipment.quantity === "number" ? selectedShipment.quantity : TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.batchNo }}</dt>
|
||||
<dd>{{ selectedShipment.batch_no || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.carrier }}</dt>
|
||||
<dd>{{ selectedShipment.carrier || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.trackingNo }}</dt>
|
||||
<dd>{{ selectedShipment.tracking_no || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.remark }}</dt>
|
||||
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
|
||||
</dl>
|
||||
<div class="preview-actions">
|
||||
<el-button
|
||||
v-if="canUpdate"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||||
@click="openSelectedShipmentEditor"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDelete"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(row.center_id)"
|
||||
@click.stop="remove(row)"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||||
@click="removeSelectedShipment"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</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"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<span>{{ TEXT.modules.drugShipments.empty }}</span>
|
||||
<div v-else class="preview-empty">
|
||||
<span>选择一行查看发运摘要</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||
|
||||
<div
|
||||
v-if="isDesktop && shipmentContextMenu.visible && selectedShipment"
|
||||
class="shipment-context-menu"
|
||||
:style="{ left: `${shipmentContextMenu.x}px`, top: `${shipmentContextMenu.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" @click="openSelectedShipment">打开详情</button>
|
||||
<button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||||
@click="openSelectedShipmentEditor"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="danger"
|
||||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||||
@click="removeSelectedShipment"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 编辑抽屉 ==================== -->
|
||||
<el-drawer
|
||||
v-if="drawerVisible"
|
||||
@@ -290,6 +383,7 @@ import { displayDate, displayEnum } from "../../utils/display";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||
import { isTauriRuntime } from "../../runtime";
|
||||
|
||||
type ShipmentDirection = "SEND" | "RETURN";
|
||||
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
|
||||
@@ -322,6 +416,10 @@ const drawerVisible = ref(false);
|
||||
const editingId = ref("");
|
||||
const formRef = ref<FormInstance>();
|
||||
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||
const shipmentTablePaneRef = ref<HTMLElement | null>(null);
|
||||
const isDesktop = isTauriRuntime();
|
||||
const selectedShipmentId = ref("");
|
||||
const shipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
|
||||
const filters = reactive({
|
||||
center_id: study.currentSite?.id || "",
|
||||
direction: "" as "" | ShipmentDirection,
|
||||
@@ -363,6 +461,9 @@ const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !can
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
);
|
||||
const selectedShipment = computed(() =>
|
||||
sortedItems.value.find((item) => item.id === selectedShipmentId.value) || null
|
||||
);
|
||||
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
|
||||
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
|
||||
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
|
||||
@@ -564,12 +665,69 @@ const handleSearch = () => {
|
||||
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||
const onRowClick = (row: ShipmentRow) => {
|
||||
[
|
||||
row?.id ? "clickable-row" : "",
|
||||
isInactiveSite(row?.center_id) ? "row-inactive" : "",
|
||||
isDesktop && row?.id === selectedShipmentId.value ? "row-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const selectShipment = (row: ShipmentRow) => {
|
||||
if (!row?.id) return;
|
||||
selectedShipmentId.value = row.id;
|
||||
shipmentTablePaneRef.value?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
const handleShipmentRowClick = (row: ShipmentRow) => {
|
||||
if (!row?.id) return;
|
||||
if (isDesktop) {
|
||||
selectShipment(row);
|
||||
return;
|
||||
}
|
||||
router.push(`/drug/shipments/${row.id}`);
|
||||
};
|
||||
|
||||
const openShipmentDetail = (row?: ShipmentRow | null) => {
|
||||
const target = row?.id ? row : selectedShipment.value;
|
||||
if (target?.id) router.push(`/drug/shipments/${target.id}`);
|
||||
};
|
||||
|
||||
const openSelectedShipment = () => {
|
||||
closeShipmentContextMenu();
|
||||
openShipmentDetail(selectedShipment.value);
|
||||
};
|
||||
|
||||
const openSelectedShipmentEditor = () => {
|
||||
const target = selectedShipment.value;
|
||||
closeShipmentContextMenu();
|
||||
if (target) openEdit(target);
|
||||
};
|
||||
|
||||
const removeSelectedShipment = () => {
|
||||
const target = selectedShipment.value;
|
||||
closeShipmentContextMenu();
|
||||
if (target) {
|
||||
void remove(target);
|
||||
}
|
||||
};
|
||||
|
||||
const closeShipmentContextMenu = () => {
|
||||
if (!shipmentContextMenu.value.visible) return;
|
||||
shipmentContextMenu.value = { visible: false, x: 0, y: 0 };
|
||||
};
|
||||
|
||||
const openShipmentContextMenu = (row: ShipmentRow, _column: unknown, event: MouseEvent) => {
|
||||
if (!isDesktop || !row?.id) return;
|
||||
event.preventDefault();
|
||||
selectShipment(row);
|
||||
shipmentContextMenu.value = {
|
||||
visible: true,
|
||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)),
|
||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 132)),
|
||||
};
|
||||
};
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "PENDING": return "info";
|
||||
@@ -633,6 +791,22 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => sortedItems.value.map((item) => item.id).join("|"),
|
||||
() => {
|
||||
if (!isDesktop) return;
|
||||
const rows = sortedItems.value;
|
||||
if (!rows.length) {
|
||||
selectedShipmentId.value = "";
|
||||
return;
|
||||
}
|
||||
if (!rows.some((item) => item.id === selectedShipmentId.value)) {
|
||||
selectedShipmentId.value = rows[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
await load();
|
||||
@@ -648,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;
|
||||
@@ -733,6 +921,167 @@ 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;
|
||||
}
|
||||
|
||||
.shipment-workbench.is-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 316px;
|
||||
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.shipment-table-pane {
|
||||
min-width: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.shipment-preview-pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
margin-bottom: 5px;
|
||||
color: #6b7e95;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #142033;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-list dt {
|
||||
color: #7b8da3;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #223349;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8da3;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shipment-context-menu {
|
||||
position: fixed;
|
||||
z-index: 2300;
|
||||
display: flex;
|
||||
width: 172px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid #cbd7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.shipment-context-menu button {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #223349;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.shipment-context-menu button:hover:not(:disabled) {
|
||||
background: #eef4fb;
|
||||
color: #183756;
|
||||
}
|
||||
|
||||
.shipment-context-menu button.danger {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.shipment-context-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
color: #9aaabd;
|
||||
}
|
||||
|
||||
.shipment-table {
|
||||
--el-table-border-color: transparent;
|
||||
--el-table-row-hover-bg-color: #f8f9fb;
|
||||
@@ -771,6 +1120,10 @@ onMounted(async () => {
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.shipment-table :deep(.el-table__body tr.row-selected > td) {
|
||||
background: #eaf1f8 !important;
|
||||
}
|
||||
|
||||
.cell-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1035,6 +1388,15 @@ onMounted(async () => {
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.shipment-workbench.is-desktop {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.shipment-workbench.is-desktop .shipment-table-pane {
|
||||
border-right: 0;
|
||||
}
|
||||
.shipment-preview-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -1042,6 +1404,44 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .table-card) {
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-preview-pane) {
|
||||
border-color: #26364a;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-title),
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dd) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-kicker),
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dt),
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-empty) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu) {
|
||||
border-color: #334155;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button) {
|
||||
color: #dbe5f1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button:hover:not(:disabled)) {
|
||||
background: #243247;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-table .el-table__body tr.row-selected > td) {
|
||||
background: #243247 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -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: 280px minmax(0, 1fr);");
|
||||
expect(source).not.toContain("grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -411,12 +395,6 @@ const selectNode = async (node: EtmfTreeNode) => {
|
||||
await loadNodeDocuments();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.siteId = "";
|
||||
filters.status = "";
|
||||
loadNodeDocuments();
|
||||
};
|
||||
|
||||
const openNodeDialog = () => {
|
||||
if (!canCreate.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
@@ -522,151 +500,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 +734,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 +756,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 {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<div class="redirecting ctms-page-shell page--flush">
|
||||
<section class="unified-action-bar bar--flush">
|
||||
<div class="redirecting-title">{{ TEXT.menu.fileVersionManagement }}</div>
|
||||
</section>
|
||||
<section class="unified-shell">
|
||||
<section class="unified-section section--flush">
|
||||
<el-empty :description="`${TEXT.common.loading}...`" />
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
|
||||
onMounted(() => {
|
||||
const currentStudy = study.currentStudy?.id;
|
||||
if (currentStudy) {
|
||||
router.replace(`/trial/${currentStudy}/documents`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.redirecting {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.redirecting-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
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)"');
|
||||
});
|
||||
|
||||
it("aligns the desktop preview pane with the drug shipment preview layout", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("grid-template-columns: minmax(0, 1fr) 316px;");
|
||||
expect(source).toContain(".equipment-preview-pane");
|
||||
expect(source).toContain("background: #f8fafc;");
|
||||
expect(source).toContain("grid-template-columns: 78px minmax(0, 1fr);");
|
||||
expect(source).toContain("gap: 10px 12px;");
|
||||
expect(source).toContain("font-weight: 650;");
|
||||
expect(source).not.toContain("float: left;");
|
||||
expect(source).not.toContain("border-radius: 10px;");
|
||||
expect(source).not.toContain("box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);");
|
||||
});
|
||||
});
|
||||
@@ -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,115 @@
|
||||
</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 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 v-else class="preview-empty">
|
||||
<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 +230,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 +248,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 +266,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 +300,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 +388,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 +532,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 +558,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 +577,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 +631,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 +663,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 +704,57 @@ 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) 316px;
|
||||
/* 桌面端:不限定固定高度,由父容器 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: 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.equipment-table {
|
||||
--el-table-border-color: transparent;
|
||||
--el-table-row-hover-bg-color: #f8f9fb;
|
||||
@@ -543,6 +787,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 +821,125 @@ 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;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
margin-bottom: 5px;
|
||||
color: #6b7e95;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #142033;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-list dt {
|
||||
color: #7b8da3;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #223349;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8da3;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
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 +949,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 +989,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 +1101,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;
|
||||
@@ -769,4 +1133,41 @@ watch(
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .table-card) {
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-preview-pane) {
|
||||
border-color: #26364a;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-title),
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-list dd) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-kicker),
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-list dt),
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-empty) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu) {
|
||||
border-color: #334155;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button) {
|
||||
color: #dbe5f1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button:hover:not(:disabled)) {
|
||||
background: #243247;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button.danger) {
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
<template>
|
||||
<div class="page ctms-page-shell page--flush">
|
||||
<div v-if="study.currentStudy" class="unified-shell ctms-table-card">
|
||||
<section class="unified-action-bar bar--flush">
|
||||
<section class="unified-action-bar milestone-action-bar">
|
||||
<div class="card-header ctms-page-header-row">
|
||||
<div>
|
||||
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
|
||||
<div class="card-subtitle">
|
||||
{{ TEXT.modules.projectMilestones.sourceLabel }}{{ dataSourceLabel }}
|
||||
<span class="dot-sep">·</span>
|
||||
{{ TEXT.common.labels.updatedAt }}{{ updatedAtLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
|
||||
<div class="header-actions">
|
||||
<el-tag size="small" effect="plain">
|
||||
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
|
||||
@@ -200,7 +193,6 @@ import { useStudyStore } from "../../store/study";
|
||||
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
|
||||
import { TEXT } from "../../locales";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||
|
||||
@@ -237,8 +229,6 @@ const study = useStudyStore();
|
||||
const { can } = usePermission();
|
||||
const rows = ref<MilestoneRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
|
||||
const updatedAtLabel = ref<string>(TEXT.common.fallback);
|
||||
const editorVisible = ref(false);
|
||||
const editingRowId = ref("");
|
||||
const editorForm = reactive<MilestoneLocalEdit>({
|
||||
@@ -309,15 +299,8 @@ const loadMilestones = async () => {
|
||||
const { data } = (await listProjectMilestones(studyId)) as any;
|
||||
const list = Array.isArray(data) ? data : data?.items || [];
|
||||
rows.value = list.map(normalizeRow);
|
||||
const updatedAt = list
|
||||
.map((item: any) => String(item?.updated_at || ""))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.pop();
|
||||
updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback;
|
||||
} catch (error: any) {
|
||||
rows.value = [];
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -452,7 +435,6 @@ watch(
|
||||
() => study.currentStudy?.id,
|
||||
() => {
|
||||
rows.value = [];
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
loadMilestones();
|
||||
}
|
||||
);
|
||||
@@ -462,6 +444,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 {
|
||||
@@ -477,20 +485,15 @@ watch(
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.milestone-action-bar {
|
||||
min-height: 56px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f2345;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.dot-sep {
|
||||
margin: 0 8px;
|
||||
color: var(--unified-title-color);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -520,11 +523,11 @@ watch(
|
||||
border-radius: 50%;
|
||||
border: 3px solid #2f84c6;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
background: var(--ctms-bg-card);
|
||||
}
|
||||
|
||||
.plan-time-label {
|
||||
color: #4b5563;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -546,10 +549,10 @@ watch(
|
||||
}
|
||||
|
||||
.time-editor-group {
|
||||
border: 1px solid #e8eef6;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
background: #fbfcfe;
|
||||
background: var(--ctms-bg-muted);
|
||||
}
|
||||
|
||||
.time-editor-group + .time-editor-group {
|
||||
@@ -566,7 +569,7 @@ watch(
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #1a3560;
|
||||
color: var(--ctms-text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -600,14 +603,14 @@ watch(
|
||||
|
||||
.duration-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a3560;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.time-editor-footer {
|
||||
@@ -628,7 +631,7 @@ watch(
|
||||
.time-editor-form :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4a6283;
|
||||
color: var(--ctms-text-regular);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -655,7 +658,7 @@ watch(
|
||||
|
||||
.status-detail {
|
||||
padding-left: 14px;
|
||||
color: #6b7280;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -684,9 +687,10 @@ watch(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8a97ab;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,72 +1,145 @@
|
||||
<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-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>
|
||||
<span v-if="!isDesktop" class="overview-live-clock">数据 {{ overviewClockText }}</span>
|
||||
<span v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
|
||||
<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" />
|
||||
@@ -74,24 +147,145 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
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 overviewClockNow = ref(new Date());
|
||||
let overviewClockTimer: number | undefined;
|
||||
|
||||
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 overviewClockText = computed(() => {
|
||||
const date = overviewClockNow.value;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
});
|
||||
|
||||
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) => ({
|
||||
@@ -188,9 +382,17 @@ const reset = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
overviewClockNow.value = new Date();
|
||||
overviewClockTimer = window.setInterval(() => {
|
||||
overviewClockNow.value = new Date();
|
||||
}, 1000);
|
||||
loadOverview();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (overviewClockTimer) window.clearInterval(overviewClockTimer);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => study.currentStudy?.id,
|
||||
() => {
|
||||
@@ -218,6 +420,12 @@ watch(
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.overview-workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -230,6 +438,14 @@ watch(
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.overview-live-clock,
|
||||
.overview-updated-at {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: var(--ctms-bg-card);
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
@@ -389,6 +605,385 @@ 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;
|
||||
}
|
||||
|
||||
.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 .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 +1001,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>
|
||||
|
||||
@@ -466,7 +466,7 @@ import {
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { pickFiles, saveFile } from "../../runtime";
|
||||
import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import type { Site } from "../../types/api";
|
||||
@@ -978,7 +978,14 @@ const handleExportExcel = async () => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
await saveFileWithFeedback(
|
||||
{ suggestedName: filename, mimeType: contentType, data: blob },
|
||||
{
|
||||
kind: "export",
|
||||
title: "导出文件",
|
||||
completedDetail: "导出文件已保存",
|
||||
},
|
||||
);
|
||||
ElMessage.success("导出成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
|
||||
@@ -1010,7 +1017,7 @@ const importIssueFile = async (file: File) => {
|
||||
};
|
||||
|
||||
const selectImportFile = async () => {
|
||||
const [file] = await pickFiles({
|
||||
const [file] = await pickFilesWithFeedback({
|
||||
multiple: false,
|
||||
accept: ["xlsx", "csv"],
|
||||
title: "导入监查访视问题",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.riskIssues.title"
|
||||
:subtitle="TEXT.modules.riskIssues.subtitle"
|
||||
:list-title="TEXT.modules.riskIssues.listTitle"
|
||||
:empty-description="TEXT.modules.riskIssues.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user