可拖拽分栏布局:两种实现方式详解
基于
HandoverQuery(交接查询)和AssayReceive(样品接收)两份代码的对比整理。
一、两份代码都在做什么
两者都是同一个模式:LayoutBasePage 的 #main 里放一个 el-container,里面再塞「上区 + 分割线 + 下区」三件套,上下两区各自可以拖动分割线调整尺寸。
#main
└─ el-container.main-container ← 容器(getContainer 的目标)
├─ div.main-main ← 上区(固定高度,可拖)
├─ BaseResizer ← 上下分割线
└─ el-footer.main-footer ← 下区(自适应,flex: 1)
└─ el-container.main-footer-container
├─ 左侧区块 ← 固定宽度,可拖
├─ BaseResizer ← 左右分割线
└─ 右侧区块 ← 自适应
两份代码唯一的差异集中在两点:
| 差异点 | HandoverQuery |
AssayReceive |
|---|---|---|
| Hook | useResizableSize + 手写 calcMax + useClampOnResize |
useResizableRegion(内置一切) |
| 左右分割绑谁 | 绑右侧宽度 | 绑左侧宽度 |
其余全部一致(操作栏都在 #header,#main 里都只含三件套)。
二、差异一:Hook 用法
2.1 HandoverQuery 的写法(分散式)
一个「上区可拖拽」功能,拆成 4 个独立部分:
① 状态存值 ------ useResizableSize
const mainTableHeight = useResizableSize("handover-query-main-table-height", DEFAULT_MAIN_TABLE_HEIGHT);
-
只负责「存值 + localStorage 持久化」。
-
不管容器、不管 max、不管 min。
② 动态 max ------ 手写 calcMaxTableHeight()
const calcMaxTableHeight = (): number => {
const containerEl = getElementFromRef(mainContainerRef.value); // 取根 DOM
if (!containerEl) return MAX_MAIN_TABLE_HEIGHT_FALLBACK;
const containerHeight = containerEl.clientHeight;
if (!containerHeight) return MAX_MAIN_TABLE_HEIGHT_FALLBACK;
const resizerEl = containerEl.querySelector(".resizer--vertical"); // 找分割线
const resizerHeight = resizerEl?.offsetHeight ?? RESIZER_WIDTH;
return Math.max(100, containerHeight - resizerHeight - MIN_MAIN_FOOTER_HEIGHT);
};
-
自己算:
容器高 - 分割线高 - 下区最小高。 -
自己用
querySelector定位分割线。 -
自己兜底 DOM 未就绪的情况。
③ DOM 取根 ------ 手写 getElementFromRef
const getElementFromRef = (instance) => {
if (!instance) return null;
if (instance instanceof HTMLElement) return instance;
const el = instance.$el as HTMLElement | undefined; // 组件实例 → $el
return el instanceof HTMLElement ? el : null;
};
-
el-container是组件,ref拿到组件实例,要.$el才拿到根 DOM。 -
这个工具函数每个页面都要写一遍。
④ 窗口 clamp ------ 额外挂 useClampOnResize
useClampOnResize({
size: mainTableHeight,
getContainer: () => getElementFromRef(mainContainerRef.value),
getMax: calcMaxTableHeight
});
-
窗口缩小时,自动把
mainTableHeight约束到动态 max。 -
必须手动挂,忘了挂就会出现「窗口缩小后上区溢出」。
⑤ 模板传给 BaseResizer
<BaseResizer
v-model="mainTableHeight"
direction="vertical"
variant="block"
:min="82" <!-- 硬编码 -->
:max="calcMaxTableHeight" <!-- 传函数 -->
:default-size="DEFAULT_MAIN_TABLE_HEIGHT" />
2.2 AssayReceive 的写法(集中式)
一个 hook 全部搞定:
const { size: headerHeight, getMax: calcMaxHeaderHeight } = useResizableRegion({
direction: "vertical",
storageKey: "assay-receive-header-height",
defaultSize: DEFAULT_HEADER_HEIGHT,
minSize: MIN_HEADER_HEIGHT, // 自己能压到多小
reserveSize: MIN_MAIN_HEIGHT, // 对面要留多少
getContainer: () => containerRef.value
});
hook 内部帮你干了什么(对照 HandoverQuery):
| 内部做的事 | 对应 HandoverQuery 的哪部分 |
|---|---|
| 响应式 state + localStorage 读写 | useResizableSize |
| 监听容器尺寸变化,重新计算 max | useClampOnResize |
max = clientHeight - resizer - reserveSize |
手写 calcMaxTableHeight |
组件实例 .$el 的兼容 |
手写 getElementFromRef |
defaultRatio 按容器宽换算 |
不支持,要手写 |
| 兜底 DOM 未就绪 | 各页面自己写 |
模板也简化了:
<BaseResizer
v-model="headerHeight"
direction="vertical"
variant="block"
:min="MIN_HEADER_HEIGHT" <!-- 用常量,不硬编码 -->
:max="calcMaxHeaderHeight" <!-- hook 提供的 getMax -->
:default-size="DEFAULT_HEADER_HEIGHT" />
三、差异二:左右分割绑谁 ⭐ 最关键的应用层差异
3.1 先看现象
HandoverQuery 下区:
<el-main class="main-footer-main">左侧,自适应
<BaseResizer v-model="asideItemTableWidth" /> <!-- 绑右侧 -->
<el-aside class="main-footer-aside" :style="{ width: asideItemTableWidth }">右侧,固定
-
往右拖 →
asideItemTableWidth变大 → 右侧变宽 → 分割线左移 → 左侧变小。 -
用户鼠标往右,分割线往左,方向相反。
AssayReceive 下区:
<el-aside class="main-footer-aside" :style="{ width: mainLeftWidth }">左侧,固定
<BaseResizer v-model="mainLeftWidth" /> <!-- 绑左侧 -->
<el-main class="main-footer-main">右侧,自适应
-
往右拖 →
mainLeftWidth变大 → 左侧变宽 → 分割线右移 → 右侧变小。 -
分割线跟着鼠标走。
3.2 为什么会这样
BaseResizer 内部只认一件事:
鼠标往右移 Δ → modelValue += Δ
它不知道 你绑的是哪一侧。modelValue 变大 → 绑定该值的容器变大 → 该容器的边界(分割线)朝相反方向移动。
所以:
-
绑右侧 宽度 → 往右拖右侧变大 → 分割线左移 → 反直觉
-
绑左侧 宽度 → 往右拖左侧变大 → 分割线右移 → 符合直觉
3.3 对比表
HandoverQuery |
AssayReceive |
|
|---|---|---|
| 固定侧位置 | 右侧 | 左侧 |
| v-model 绑定 | 右侧宽度 | 左侧宽度 |
| 往右拖 | 右侧变大,分割线左移 | 左侧变大,分割线右移 |
| 分割线跟手? | 否 | 是 |
| 用户直觉 | 别扭 | 自然 |
3.4 判定口诀
v-model 绑到「被推大的那一侧」,即靠近「左上角」的那一侧。
左右分割 → 绑左侧 宽度 → 往右拖左侧变大,鼠标和分割线同向移动 ✅
上下分割 → 绑上侧 高度 → 往下拖上侧变大,鼠标和分割线同向移动 ✅
3.5 结论
AssayReceive 的绑定方式是标准,HandoverQuery 的绑定方式是反面教材。
四、共同点:操作栏必须在 #header
两个页面都把操作栏放在 #header 插槽里:
<template #header>
<CommonPageTitle />
<LayoutActionBar>
<!-- 操作栏内容 -->
</LayoutActionBar>
</template>
为什么必须这样?
useResizableRegion(或手写的 calcMax)计算上区 max 的公式是:
max = containerClientHeight - resizerHeight - reserveSize
containerClientHeight 来自 getContainer() 返回的容器。这个容器只应包含「上区 + 分割线 + 下区」三件套。
如果操作栏塞进了这个容器:
el-container.main-container
├─ el-header.header-operate ← ⚠️ 多出来的操作栏,约 40px
├─ div.main-main(上区)
├─ BaseResizer(分割线)
└─ el-footer.main-footer(下区)
clientHeight 会多算操作栏的 40px,而公式里没减去它 → 上区 max 高估 40px → 用户能把分割线拖到上区多吃 40px → 下区实际只剩 82 - 40 = 42px → 表头占 40px 后,空数据/底部边框被 overflow: hidden 裁掉,现象是**「分割线拉到底时下区表格少一行」**。
这是 AssayReceive 曾经踩过的坑,修复方式就是把操作栏从 #main 挪到 #header。
五、实现方式对比总表
| 维度 | HandoverQuery |
AssayReceive |
|---|---|---|
| Hook | useResizableSize |
useResizableRegion |
| max 计算 | 手写 calcMaxXxxHeight() |
hook 内置 |
| 窗口 clamp | 手动挂 useClampOnResize |
hook 内置 |
| 容器取根 | 手写 getElementFromRef |
hook 内部处理 |
| 首次默认值 | 固定 px | 固定 px 或 defaultRatio |
| min 语义 | 分散在 :min 和 calcMax 里 |
集中:minSize / reserveSize |
| 常量同步 | 容易漏(:min="82" 硬编码) |
集中管 |
| 代码量 | 多(一个功能散在 6 处) | 少(配置 + 模板 2 处) |
| 可维护性 | 差 | 好 |
| 拖动方向 | 反直觉(分割线反向) | 符合直觉(分割线跟手) |
| 操作栏位置 | #header ✅ |
#header ✅ |
六、应用场景与推荐
6.1 Hook 选型:一律用 useResizableRegion
新页面:
| 需求 | 采用 |
|---|---|
| 可拖拽分栏 | useResizableRegion |
| 拖动方向 | 固定侧放左/上 ,v-model 绑左/上 |
| 操作栏位置 | #header 插槽 |
HandoverQuery 什么时候还看?
-
维护遗留页面:已经跑通、没 bug,不必强行重构。
-
反面教材:它的左右分割绑定方式(绑右侧)不要抄。
6.2 布局结构铁律
| 规则 | 说明 |
|---|---|
操作栏放 #header 插槽 |
别塞 #main,否则污染 clientHeight |
#main 只放三件套 |
上区 + 分割线 + 下区 |
| 固定侧放「左上角」那一侧 | 左右分割 → 固定放左边 ;上下分割 → 固定放上边 |
| v-model 绑固定侧 | 往右拖左侧变大(分割线右移);往下拖上侧变大(分割线下移) |
分页放 #footer 插槽 |
别塞 #main |
七、新页面落地清单
第 1 步:结构设计
-
操作栏放
#header插槽(LayoutActionBar包裹) -
#main里只有三件套:上区 + 分割线 + 下区 -
固定侧放左边(左/上),v-model 绑左侧宽度 / 上侧高度
-
分页放
#footer插槽
第 2 步:Hook 选型
-
首选
useResizableRegion -
每个分割线一个 hook 实例
-
getContainer返回「只含三件套」的容器 -
direction与语义对齐:vertical= 上下,horizontal= 左右
第 3 步:常量命名
-
带方向:
MIN_LEFT_WIDTH、DEFAULT_MAIN_LEFT_WIDTH -
注释里写清「和 CSS 哪一行同步」
第 4 步:CSS 对齐
-
JS 常量 ↔ CSS
min-height/min-width -
固定侧:
:style="{ width: xxx + 'px' }"+flex-shrink: 0 -
自适应侧:
min-width/min-height -
外层层层
overflow: hidden
第 5 步:回归测试
-
每条分割线拖到极限,确认分割线跟着鼠标走(往右拖分割线右移)
-
缩放浏览器窗口,看 clamp 是否生效
-
空数据 / 满数据两种场景
八、一句话记忆
Hook 用
useResizableRegion;固定侧放左上角,v-model 绑左上角(分割线跟手);
操作栏放
#header;常量与 CSS 双向同步。
AssayReceive 就是标准实现
HandoverQuery 的左右分割绑定方式(绑右侧,往右拖左侧变小)是反面教材
九、代码示例
样品接收:src\views\assay\AssayReceive.vue
TypeScript
<script setup lang="ts">
/**
* 检测接样(样品接收)
*/
defineOptions({
name: "AssayReceive"
});
import { batchUpdateHandoverForAssayReceive, queryHandoverPageForReceive } from "@/api";
import {
BasePreventReClickButton,
BaseResizer,
CommonApplySampleTable,
CommonPageTitle,
HandoverItemBriefDisplayTable,
LayoutActionBar,
LayoutBasePage,
SingleQueryOperation
} from "@/components";
import { useHandoverSampleAndItem, useResizableRegion } from "@/hooks";
import type { HandoverQueryRequest, HandoverResponse } from "@/openapi/types.gen";
import { useModuleStore } from "@/stores";
import type { SingleQueryParam } from "@/types";
import { CommonOperationCommand, HandoverDataCommand, HandoverOperationCommand, ModuleType } from "@/types";
import { computed, nextTick, onMounted, ref, type ComponentPublicInstance } from "vue";
import { HandoverReceiveTable } from "./components";
// ========== 常量 ==========
// ---------- 上区(交接接样表格) ----------
/**
* 上区默认高度(px)
* - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值)
*/
const DEFAULT_HEADER_HEIGHT = 400;
/**
* 上区最小高度(px)
* - 1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82
*/
const MIN_HEADER_HEIGHT = 82;
// ---------- 下区(交接样品 + 交接项目) ----------
/**
* 下区最小高度(px)
* - 作为上区拖拽上限的计算依据,保证下区始终可用
*
* ⚠️ 与 .main-footer 的 min-height 保持一致,改动时两处同步
*/
const MIN_MAIN_HEIGHT = 82;
// ---------- 下区左右区 ----------
/**
* 下区左侧默认比例(0~1)
* - 0.7 = 左侧(样品)占 70%,右侧(项目)占 30%
* - 仅首次进入(无 localStorage 值)时生效
* - 之后用户在界面上拖拽产生的值会被保存,覆盖此比例
*/
const DEFAULT_MAIN_LEFT_RATIO = 0.7;
/**
* 下区左侧默认宽度兜底值(px)
* - 仅当 DOM 未就绪或测量失败时使用
* - 正常情况下由 DEFAULT_MAIN_LEFT_RATIO 按容器宽度换算
*/
const DEFAULT_MAIN_LEFT_WIDTH = 900;
/**
* 下区左侧最小宽度(px)
* - 用户拖拽分割线时不能把左侧压到看不见
*/
const MIN_LEFT_WIDTH = 200;
/**
* 下区右侧最小宽度(px)
* - 作为左侧拖拽上限的计算依据,保证右侧始终可用
*
* ⚠️ 与 .main-footer-main 的 min-width 保持一致,改动时两处同步
*/
const MIN_RIGHT_WIDTH = 200;
// ========== 状态管理(Store) ==========
const moduleStore = useModuleStore();
// ========== 组合式函数(Composables) ==========
// 交接样品列表、交接项目列表、加载标识、获取交接样品和项目
const {
handoverSamples,
handoverItems,
isLoading: isDetailLoading,
fetchHandoverSampAndItem
} = useHandoverSampleAndItem();
// ========== 模板实例引用(Template Refs) ==========
/**
* 上区高度计算用容器(可拖拽区域)
*
* ⭐ 关键:此容器只包含【上区 + 上下分割线 + 下区】三件套,
* 不包含 header-operate(操作栏)。
* 因为 useResizableRegion 计算 max 的公式是:
* max = clientHeight - resizerHeight - reserveSize
* 如果容器里含操作栏,clientHeight 会多出操作栏高度,但公式里没减去它,
* 导致上区 max 被高估 → 下区被挤到不足 reserveSize(82px)→ 底部被裁。
* HandoverQuery 之所以正常,正是因为它把操作栏放在了 #header 插槽里,
* #main 里的容器只含三件套。
*
* 因此本页也把 header-operate 移到 #header 插槽,与本容器完全隔离。
*/
const containerRef = ref<ComponentPublicInstance | null>(null);
// 下区容器(用于下区左侧宽度计算)
const mainContainerRef = ref<ComponentPublicInstance | null>(null);
// ========== 组合式函数(Composables) ==========
// ---------- 上区高度(上下分割) ----------
/**
* 上区高度(px)------垂直分割,可拖拽,带 localStorage 持久化
*/
const { size: headerHeight, getMax: calcMaxHeaderHeight } = useResizableRegion({
direction: "vertical",
storageKey: "assay-receive-header-height",
defaultSize: DEFAULT_HEADER_HEIGHT,
minSize: MIN_HEADER_HEIGHT,
reserveSize: MIN_MAIN_HEIGHT,
getContainer: () => containerRef.value
});
// ---------- 下区左侧宽度(左右分割) ----------
/**
* 下区左侧宽度(px)------水平分割,可拖拽,带 localStorage 持久化
* - 首次进入:按 DEFAULT_MAIN_LEFT_RATIO 分配(容器宽 × RATIO)
* - 双击分割线:按当前容器宽 × RATIO 恢复
*/
const {
size: mainLeftWidth,
getMax: calcMaxMainLeftWidth,
defaultSize: defaultMainLeftWidth
} = useResizableRegion({
direction: "horizontal",
storageKey: "assay-receive-main-left-width",
defaultSize: DEFAULT_MAIN_LEFT_WIDTH,
defaultRatio: DEFAULT_MAIN_LEFT_RATIO,
minSize: MIN_LEFT_WIDTH,
reserveSize: MIN_RIGHT_WIDTH,
getContainer: () => mainContainerRef.value
});
// ========== 响应式状态(Ref / Reactive) ==========
// 模块名称
const moduleName = ref<string | null>(null);
// 交接列表、选集、当前选择行、加载状态
const handovers = ref<HandoverResponse[]>([]);
const handoverSelection = ref<HandoverResponse[]>([]);
const currentHandover = ref<HandoverResponse | null>(null);
const isMasterLoading = ref(false);
// 查询参数
const paramObj = ref<SingleQueryParam>({
paramName: "",
paramValue: ""
});
// 查询请求对象
const queryRequest = ref<HandoverQueryRequest>({
dataCommand: HandoverDataCommand.TO_BE_RECEIVE,
current: 1,
size: 200,
outerApplyNo: "",
sjdwName: "",
acceptType: "",
assayGroupId: null,
acceptDateRange: { beginDate: "", endDate: "" }
});
// 记录总数
const total = ref(0);
/**
* 屏蔽标志(用于阻止程序修改分页参数时引发的不必要查询)
* - 重置时使用
* - 查询前重置当前页码 current 为 1 时使用
* @example
* // 设置当前页码
* isResetting.value = true;
* current.value = 1;
* await nextTick();
* isResetting.value = false;
*
* // 分页改变
* const handlePageChange = async (currentPage: number, pageSize: number) => {
* if (isResetting.value) return;
* // 其他代码
* };
*/
const isResetting = ref(false);
// ========== 计算属性(Getter) =========
// ---------- 按钮禁用状态 ----------
const buttonDisabled = computed(() => handoverSelection.value.length === 0);
// 当前交接的受理类别
const acceptType = computed(() => {
return currentHandover.value?.acceptTypeCode ?? "";
});
// ========== API 请求方法 ==========
/**
* 获取交接分页
*/
const fetchHandoverPage = async () => {
initData();
try {
isMasterLoading.value = true;
// 重置查询字段
queryRequest.value.outerApplyNo = "";
queryRequest.value.sjdwName = "";
queryRequest.value.acceptType = "";
if (paramObj.value.paramName === "outerApplyNo") {
queryRequest.value.outerApplyNo = paramObj.value.paramValue;
} else if (paramObj.value.paramName === "sjdwName") {
queryRequest.value.sjdwName = paramObj.value.paramValue;
} else if (paramObj.value.paramName === "acceptType") {
queryRequest.value.acceptType = paramObj.value.paramValue;
}
const result = await queryHandoverPageForReceive(queryRequest.value);
handovers.value = result.data.rows;
total.value = result.data.total;
} catch (error) {
handovers.value = [];
} finally {
isMasterLoading.value = false;
}
};
/**
* 通过当前选择的交接,刷新页面数据
* @param handover 交接
*/
const refreshPageData = async (handover: HandoverResponse) => {
await fetchHandoverSampAndItem(handover.id);
};
// ========== 业务辅助方法 ==========
/**
* 初始化数据
*/
const initData = () => {
handoverSamples.value = [];
handoverItems.value = [];
currentHandover.value = null;
};
/**
* 重置查询请求页码
*/
const resetQueryRequestCurrent = async () => {
// 屏蔽分页变化触发的查询(重置 current 时可能触发 @change)
isResetting.value = true;
queryRequest.value.current = 1;
// ⏳ 等待分页组件更新完成,确保分页组件的微任务 change 已被拦截
await nextTick();
isResetting.value = false;
};
// ========== 事件处理 ==========
/**
* 接收
*/
const handleReceive = async () => {
// 获取选集的 ID 列表
const selectedIds = new Set(handoverSelection.value.map((item) => item.id));
if (selectedIds.size === 0) return;
try {
await batchUpdateHandoverForAssayReceive(HandoverOperationCommand.ASSAY_RECEIVE_SUBMIT, Array.from(selectedIds));
// 从列表中移除已处理的数据(基于 ID 过滤)
handovers.value = handovers.value.filter((item) => !selectedIds.has(item.id));
// 清空选集和当前行
handoverSelection.value = [];
initData();
} catch (error) {
console.error("接收失败:", error);
}
};
/**
* 退回
*/
const handleReject = async () => {
// 获取选集的 ID 列表
const selectedIds = new Set(handoverSelection.value.map((item) => item.id));
if (selectedIds.size === 0) return;
try {
await batchUpdateHandoverForAssayReceive(HandoverOperationCommand.ASSAY_RECEIVE_REJECT, Array.from(selectedIds));
// 从列表中移除已处理的数据(基于 ID 过滤)
handovers.value = handovers.value.filter((item) => !selectedIds.has(item.id));
// 清空选集和当前行
handoverSelection.value = [];
initData();
} catch (error) {
console.error("退回失败:", error);
}
};
/**
* 刷新
*/
const handleRefresh = async () => {
await resetQueryRequestCurrent();
await fetchHandoverPage();
};
/**
* 查找,父子通讯,获取查询参数,获取子组件的数据(查询参数)
* @param value 查询参数
*/
const handleSearch = async (value: SingleQueryParam) => {
paramObj.value = value;
await resetQueryRequestCurrent();
await fetchHandoverPage();
};
/**
* 父子通讯,获取交接,获取子组件的数据(选中的交接)
* @param handover 交接
*/
const getHandover = async (handover: HandoverResponse) => {
// 获取子组件中选择的交接
currentHandover.value = handover;
// 通过当前选择的交接,刷新页面数据
await refreshPageData(currentHandover.value);
};
/**
* 父子通讯,获取交接选集,获取子组件的数据(交接选集)
* @param selection 交接选集
*/
const getHandoverSelection = (selection: HandoverResponse[]) => {
// 获取子组件中的交接选集
handoverSelection.value = selection;
};
/**
* 分页改变
*/
const handlePageChange = async (currentPage: number, pageSize: number) => {
// 若正处于重置流程,忽略分页变化触发的查询
if (isResetting.value) return;
queryRequest.value.current = currentPage;
queryRequest.value.size = pageSize;
await fetchHandoverPage();
};
// ========== 生命周期(Lifecycle) ==========
onMounted(async () => {
moduleName.value = await moduleStore.getModuleName(ModuleType.ASSAY_RECEIVE);
await handleRefresh();
});
</script>
<template>
<LayoutBasePage>
<template #header>
<!-- 页面标题 -->
<CommonPageTitle :title="moduleName ?? `样品接收`" />
<!--
操作栏:放在 #header 插槽(对齐 HandoverQuery 的布局)
⭐ 让 #main 里的 main-container 只包含【上区 + 分割线 + 下区】三件套,
useResizableRegion 的 max 计算才能与 HandoverQuery 完全一致。
-->
<LayoutActionBar>
<div class="header-operate">
<BasePreventReClickButton
class="header-operate-button"
type="primary"
:disabled="buttonDisabled"
@click="handleReceive"
>接收</BasePreventReClickButton
>
<BasePreventReClickButton
class="header-operate-button"
type="warning"
plain
:disabled="buttonDisabled"
@click="handleReject"
>退回</BasePreventReClickButton
>
<BasePreventReClickButton class="header-operate-button" type="primary" plain @click="handleRefresh"
>刷新</BasePreventReClickButton
>
<!-- 简单查询 -->
<SingleQueryOperation @single-query-object="handleSearch" />
</div>
</LayoutActionBar>
</template>
<template #main>
<!--
⭐ 与 HandoverQuery 的 main-container 完全同构:
容器里只有三件套:上区 + 分割线 + 下区
-->
<el-container class="main-container" ref="containerRef">
<!-- ⭐ 上区:高度由 headerHeight 控制(响应式绑定) -->
<div class="main-main" :style="{ height: `${headerHeight}px` }" v-loading="isMasterLoading">
<!-- 交接接样表格 -->
<HandoverReceiveTable
:handovers="handovers"
@current-change="getHandover"
@selection-change="getHandoverSelection" />
</div>
<!-- 拖拽分割线(上下分割,调整上区高度) -->
<!-- min: 1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82 -->
<BaseResizer
v-model="headerHeight"
direction="vertical"
variant="block"
:min="MIN_HEADER_HEIGHT"
:max="calcMaxHeaderHeight"
:default-size="DEFAULT_HEADER_HEIGHT" />
<el-footer class="main-footer" v-loading="isDetailLoading">
<el-container class="main-footer-container" ref="mainContainerRef">
<el-aside class="main-footer-aside" :style="{ width: `${mainLeftWidth}px` }">
<!-- 交接样品表格 -->
<CommonApplySampleTable
:apply-samples="handoverSamples"
:accept-type="acceptType"
:operation-command="CommonOperationCommand.VIEW" />
</el-aside>
<!-- 拖拽分割线(左右分割,调整右侧项目表格宽度) -->
<BaseResizer
v-model="mainLeftWidth"
direction="horizontal"
variant="block"
:min="MIN_LEFT_WIDTH"
:max="calcMaxMainLeftWidth"
:default-size="defaultMainLeftWidth" />
<el-main class="main-footer-main">
<!-- 交接项目表格 -->
<HandoverItemBriefDisplayTable :tableData="handoverItems" />
</el-main>
</el-container>
</el-footer>
</el-container>
</template>
<template #footer>
<!-- 分页 -->
<el-pagination
:total="total"
:page-sizes="[20, 50, 100, 200, 500]"
v-model:page-size="queryRequest.size"
v-model:current-page="queryRequest.current"
background
layout="total, sizes, prev, pager, next, jumper"
@change="handlePageChange" />
</template>
</LayoutBasePage>
</template>
<style scoped lang="scss">
// ========== 主区容器(上区 + 分割线 + 下区,不含操作栏) ==========
.main-container {
height: 100%;
padding: 0px;
margin: 0px;
// ---------- 上区(高度由 JS 控制,通过 :style 绑定) ----------
.main-main {
padding: 0px;
margin: 0px;
flex-shrink: 0; // 高度由 JS 精确控制,不允许被 flex 压缩,否则用户拖拽无效
overflow: hidden; // ⭐ 自身不滚动,让内部组件(表格)自己滚动
}
// ---------- 下区(自动占剩余空间) ----------
.main-footer {
flex: 1; // ⭐ 占据剩余空间,避免空白(主表格高度变了,footer 跟着变)
// ⚠️ 与 JS 常量 MIN_MAIN_FOOTER_HEIGHT 保持一致,改动时两处同步
min-height: 82px; // ⭐ flex item 默认 min-height: auto,会阻止 flex: 1 正确收缩
overflow: hidden; // ⭐ 自身不滚动,让内部组件自己滚动
height: auto; // 显式声明高度由 flex / 内容决定,不依赖 el-footer 默认的 60px(当前 flex: 1 下不生效,作为布局变化时的兜底)
padding: 0px;
margin: 0px;
.main-footer-container {
height: 100%;
padding: 0px;
margin: 0px;
// 下区左侧(固定宽度,放样品表)
.main-footer-aside {
flex-shrink: 0; // 宽度由 JS 精确控制,不允许被 flex 压缩,否则用户拖拽无效
}
// 下区右侧(自适应,放项目表)
.main-footer-main {
padding: 0px;
margin: 0px;
// ⚠️ 与 JS 常量 MIN_RIGHT_WIDTH 保持一致,改动时两处同步
min-width: 200px;
}
}
}
}
// ========== 操作栏(位于 #header 插槽内) ==========
.header-operate {
padding: 0;
margin: 0;
}
// ========== 操作栏按钮 ==========
.header-operate-button {
margin: 0 15px 0 0;
width: 70px;
}
</style>
交接查询:src\views\accept\HandoverQuery.vue
TypeScript
<script setup lang="ts">
/**
* 交接查询
*
* 【这里页头查询栏的样式布局比较有特性,实现起来踩了很多坑】
* - 父组件要给"封装组件的内部元素"定尺寸 → 用 :style(inline),别用 scoped class
* 原因:BasePreventReClickButton 是封装组件,父组件的 <style scoped> 打不到它内部的 el-button;
* 而 inline style 是 $attrs 的一部分,会穿透 scoped,直达 el-button 根元素。
* - "隔太开"是 el-form-item 默认 margin-bottom: 18px → 显式收紧才能贴近
*/
defineOptions({
name: "HandoverQuery"
});
import { printTemplateApi, queryAssayGroupOptions, queryHandoverPageForQuery } from "@/api";
import {
BasePreventReClickButton,
BaseResizer,
CommonApplySampleTable,
CommonPageTitle,
HandoverItemBriefDisplayTable,
LayoutActionBar,
LayoutBasePage,
PrintDrawer,
PrintDynamicDrawer
} from "@/components";
import { useClampOnResize, useHandoverSampleAndItem, useResizableSize } from "@/hooks";
import type { AssayGroupKeyValueResponse, HandoverQueryRequest, HandoverResponse } from "@/openapi/types.gen";
import { useModuleStore } from "@/stores";
import {
CommonOperationCommand,
HandoverDataCommand,
HandoverStatus,
HandoverTemplate,
ModuleType,
PrintType
} from "@/types";
import type { ElTable } from "element-plus";
import { ElMessage } from "element-plus";
import { computed, onMounted, ref, watch, type ComponentPublicInstance } from "vue";
// ========== 常量 ==========
// ---------- 主区表格 ----------
/**
* 主区表格默认高度(px)
* - 初始 400px,兼顾"表格能显示足够行数"与"footer 保留足够空间"
* - 用于:
* ① useResizableSize 的初始值(首次加载 / 无 localStorage 时)
* ② BaseResizer 的 default-size(双击分割线恢复值)
* - 一处定义,两处引用------避免改一处忘另一处
*/
const DEFAULT_MAIN_TABLE_HEIGHT = 400;
/**
* 主区表格最大高度兜底值(px)
* - 用于 DOM 未就绪(如首屏 onMounted 前)或测量失败时的上限
* - 正常情况下"最大高度"由 calcMaxTableHeight() 动态计算
*/
const MAX_MAIN_TABLE_HEIGHT_FALLBACK = 500;
/**
* main-footer 最小高度(px)
* - 含义:交接样品表格 + 交接项目表格至少要能放下的高度(1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82)
* - 作用:作为主区表格拖拽上限的计算依据,保证 main-footer 始终可用
*
* ⚠️ 与 .main-footer 的 min-height 保持一致,改动时两处同步
* - JS 侧(此常量):主动约束"主区表格最高能到多少",拖拽时不让 main-footer 被挤出
* - CSS 侧(min-height):被动兜底,万一 JS 计算出错也能保住 main-footer
* - 改动时两处同步
*/
const MIN_MAIN_FOOTER_HEIGHT = 82;
// ---------- 右侧项目表格 ----------
/**
* 右侧项目表格默认宽度(px)
* - 初始 400px,兼顾"表格能显示足够行数"与"footer 保留足够空间"
* - 用于:
* ① useResizableSize 的初始值(首次加载 / 无 localStorage 时)
* ② BaseResizer 的 default-size(双击分割线恢复值)
* - 一处定义,两处引用------避免改一处忘另一处
*/
const DEFAULT_ASIDE_ITEM_TABLE_WIDTH = 400;
/**
* 右侧项目表格最大宽度兜底值(px)
* - 用于 DOM 未就绪(如首屏 onMounted 前)或测量失败时的上限
* - 正常情况下"最大宽度"由 calcMaxAsideItemTableWidth() 动态计算
*/
const MAX_ASIDE_ITEM_TABLE_WIDTH_FALLBACK = 500;
/**
* main-footer-main 最小宽度(px)
* - 含义:交接样品表格最小宽度
* - 作用:作为右侧项目表格拖拽上限的计算依据,保证 main-footer-main 始终可用
*
* ⚠️ 与 main-footer-main 的 min-width 保持一致,改动时两处同步
* - JS 侧(此常量):主动约束"右侧项目表格最宽能到多少",拖拽时不让 main-footer-main 被挤出
* - CSS 侧(min-width):被动兜底,万一 JS 计算出错也能保住 main-footer-main
* - 改动时两处同步
*/
const MIN_MAIN_FOOTER_MAIN_WIDTH = 200;
/**
* 分割线粗细(px)
* - 与 BaseResizer 的 .resizer--vertical/.resizer--horizontal 的 height/width 保持一致
* - 用于动态 max 计算,避免硬编码
*/
const RESIZER_WIDTH = 5;
// ========== 状态管理(Store) ==========
const moduleStore = useModuleStore();
// ========== 组合式函数(Composables) ==========
// 交接样品列表、交接项目列表、加载标识、获取交接样品和项目
const {
handoverSamples,
handoverItems,
isLoading: isDetailLoading,
fetchHandoverSampAndItem
} = useHandoverSampleAndItem();
/** 主区表格高度(px) */
const mainTableHeight = useResizableSize("handover-query-main-table-height", DEFAULT_MAIN_TABLE_HEIGHT);
/** 右侧表格宽度(px) */
const asideItemTableWidth = useResizableSize("handover-query-aside-item-table-width", DEFAULT_ASIDE_ITEM_TABLE_WIDTH);
// ========== 响应式状态(Ref / Reactive) ==========
// 模块名称
const moduleName = ref<string | null>(null);
// 交接列表、选集、当前选择行、加载标识
const handovers = ref<HandoverResponse[]>([]);
const handoverSelection = ref<HandoverResponse[]>([]);
const currentHandover = ref<HandoverResponse | null>(null);
const handoverTableLoading = ref(false);
// 抽屉显示标识
const drawerVisible = ref(false);
// 打印按钮禁用标识
const buttonPrintDisabled = ref(true);
// 查询请求对象
const queryRequest = ref<HandoverQueryRequest>({
dataCommand: HandoverDataCommand.QUERY,
current: 1,
size: 20,
outerApplyNo: "",
sjdwName: "",
acceptType: "",
assayGroupId: null,
acceptDateRange: { beginDate: "", endDate: "" }
});
// 记录总数
const total = ref(0);
/**
* 屏蔽标志(用于阻止程序修改分页参数时引发的不必要查询)
* - 重置时使用
* - 查询前重置当前页码 current 为 1 时使用
* @example
* // 设置当前页码
* isResetting.value = true;
* current.value = 1;
* await nextTick();
* isResetting.value = false;
*
* // 分页改变
* const handlePageChange = async (currentPage: number, pageSize: number) => {
* if (isResetting.value) return;
* // 其他代码
* };
*/
const isResetting = ref(false);
// 检验组别选项列表
const assayGroupOptions = ref<AssayGroupKeyValueResponse[]>([]);
// 打印模板值
const printTemplateValue = ref<HandoverTemplate>(HandoverTemplate.SINGLE_SAMPLE);
// 新增响应式数据
const printTemplateMode = ref("STATIC_SINGLE");
const selectedDynamicTemplateId = ref<number | null>(null);
const dynamicTemplates = ref<any[]>([]);
const dynamicDrawerVisible = ref(false);
// ========== 计算属性(Getter / Setter) =========
/**
* 日期范围双向绑定转换
* - 将 el-date-picker 的 [string, string] 与后端 DateRange 对象相互转换
*/
const dateRangeValue = computed({
get: () => {
const { acceptDateRange } = queryRequest.value;
if (acceptDateRange?.beginDate && acceptDateRange?.endDate) {
return [acceptDateRange.beginDate, acceptDateRange.endDate];
}
return [];
},
set: (value: [string, string] | null) => {
if (value && value.length === 2) {
queryRequest.value.acceptDateRange = {
beginDate: value[0],
endDate: value[1]
};
} else {
queryRequest.value.acceptDateRange = { beginDate: "", endDate: "" };
}
}
});
// 当前交接的受理类别
const acceptType = computed(() => {
return currentHandover.value?.acceptTypeCode ?? "";
});
// ========== 模板实例引用(Template Refs) ==========
// 交接表格实例
const handoverTableRef = ref<InstanceType<typeof ElTable> | null>(null);
// 主区容器实例(el-container 是组件,ref 拿到的是组件实例,需 .$el 获取根 DOM)
const mainContainerRef = ref<ComponentPublicInstance | null>(null);
// 底部容器实例(el-container 是组件,ref 拿到的是组件实例,需 .$el 获取根 DOM)
const footerContainerRef = ref<ComponentPublicInstance | null>(null);
// ========== API 请求方法 ==========
/**
* 获取检测组别列表
*/
async function fetchAssayGroups() {
try {
const result = await queryAssayGroupOptions();
assayGroupOptions.value = result.data;
} catch (error) {
console.error("获取检测组别列表失败:", error);
}
}
/**
* 获取交接列表
*/
async function fetchHandovers() {
try {
handoverTableLoading.value = true;
handovers.value = [];
currentHandover.value = null;
const result = await queryHandoverPageForQuery(queryRequest.value);
handovers.value = result.data.rows;
total.value = result.data.total;
} catch (error) {
} finally {
handoverTableLoading.value = false;
}
}
/**
* 获取动态模板列表
*/
async function fetchDynamicTemplates() {
try {
const result = await printTemplateApi.queryTemplates({ templateType: "HANDOVER", enabled: true });
dynamicTemplates.value = result.data;
} catch (error) {
console.error("获取动态模板列表失败:", error);
}
}
// ========== 业务辅助方法 ==========
/**
* 从组件 ref 或原生 DOM 中获取根 DOM 元素
* - 组件(如 el-container):通过 .$el 取根 DOM
* - 原生 DOM(如 div):直接返回
* - 未就绪时返回 null
* @param instance 组件实例 / 原生 DOM / null
* @returns 根 DOM 元素;未就绪时返回 null
*/
const getElementFromRef = (instance: ComponentPublicInstance | HTMLElement | null): HTMLElement | null => {
if (!instance) return null;
// 原生 DOM:直接返回
if (instance instanceof HTMLElement) return instance;
// 组件实例:通过 .$el 获取根 DOM(Vue 3 类型中 $el 为 any,故加判断)
const el = instance.$el as HTMLElement | undefined;
return el instanceof HTMLElement ? el : null;
};
/**
* 计算主区表格的最大高度
*
* 公式:main 容器高度 - 纵向 resizer 高度 - footer 最小高度
*
* 为什么需要动态计算:
* - 硬编码 MAX 在不同分辨率下会失效
* - 小屏幕上会把 footer 挤没,大屏幕上又不够用
*
* @returns 主区表格能撑到的最大高度
*/
const calcMaxTableHeight = (): number => {
const containerEl = getElementFromRef(mainContainerRef.value);
if (!containerEl) return MAX_MAIN_TABLE_HEIGHT_FALLBACK;
const containerHeight = containerEl.clientHeight;
if (!containerHeight) return MAX_MAIN_TABLE_HEIGHT_FALLBACK;
// 用 .resizer--vertical 精确匹配纵向分割线(避免依赖 DOM 顺序)
const resizerEl = containerEl.querySelector(".resizer--vertical") as HTMLElement | null;
const resizerHeight = resizerEl?.offsetHeight ?? RESIZER_WIDTH;
const max = containerHeight - resizerHeight - MIN_MAIN_FOOTER_HEIGHT;
return Math.max(100, max);
};
/**
* 计算右侧项目表格的最大宽度
*
* 公式:footer 容器宽度 - 横向 resizer 宽度 - 左侧表格最小宽度
*
* @returns 右侧项目表格能撑到的最大宽度
*/
const calcMaxAsideItemTableWidth = (): number => {
const footerEl = getElementFromRef(footerContainerRef.value);
if (!footerEl) return MAX_ASIDE_ITEM_TABLE_WIDTH_FALLBACK;
const footerWidth = footerEl.clientWidth;
if (!footerWidth) return MAX_ASIDE_ITEM_TABLE_WIDTH_FALLBACK;
// 用 .resizer--horizontal 精确匹配横向分割线
const resizerEl = footerEl.querySelector(".resizer--horizontal") as HTMLElement | null;
const resizerWidth = resizerEl?.offsetWidth ?? RESIZER_WIDTH;
const max = footerWidth - resizerWidth - MIN_MAIN_FOOTER_MAIN_WIDTH;
return Math.max(100, max);
};
/**
* 打开打印抽屉
*/
const openPrintDrawer = () => {
drawerVisible.value = true;
};
/**
* 打开动态打印抽屉
*/
const openDynamicPrintDrawer = () => {
dynamicDrawerVisible.value = true;
};
// ========== 自动约束(Composables) ==========
/**
* 主区容器尺寸变化时,自动把主区表格高度约束到动态上限(防止窗口缩小时溢出)
*/
useClampOnResize({
size: mainTableHeight,
getContainer: () => getElementFromRef(mainContainerRef.value),
getMax: calcMaxTableHeight
});
/**
* 底部容器尺寸变化时,自动把右侧表格宽度约束到动态上限(防止窗口缩小时溢出)
*/
useClampOnResize({
size: asideItemTableWidth,
getContainer: () => getElementFromRef(footerContainerRef.value),
getMax: calcMaxAsideItemTableWidth
});
// ========== 事件处理 ==========
/**
* 点击表格的行,实现多选
*/
const handleRowClick = (row: HandoverResponse, column: any) => {
// 如果点击的当前行在表格选集中,那么取消选中行,否则选中行
if (handoverSelection.value.includes(row)) {
// 取消选中行
handoverTableRef.value?.toggleRowSelection(row, false);
} else {
// 选中行
handoverTableRef.value?.toggleRowSelection(row, true);
}
currentHandover.value = row;
};
/**
* 交接表格选集变化
* @param newSelection 交接选集
*/
const handleSelectionChange = (newSelection: HandoverResponse[]) => {
// 获取交接表格选集
handoverSelection.value = newSelection;
// 如果交接表格选集中没有数据,则禁用打印按钮
if (handoverSelection.value.length === 0) {
buttonPrintDisabled.value = true;
} else {
buttonPrintDisabled.value = false;
}
};
/**
* 查询
*/
const handleQuery = async () => {
// 检查查询参数
if (
!queryRequest.value.outerApplyNo &&
!queryRequest.value.sjdwName &&
!queryRequest.value.acceptType &&
!queryRequest.value.assayGroupId &&
!queryRequest.value.acceptDateRange?.beginDate &&
!queryRequest.value.acceptDateRange?.endDate
) {
ElMessage.warning("请输入查询条件");
return;
}
// 抑制分页 change 事件,避免重复请求
isResetting.value = true;
queryRequest.value.current = 1;
queryRequest.value.dataCommand = HandoverDataCommand.QUERY;
// 重要:使用 setTimeout(0) 将 isResetting 恢复延迟至宏任务,
// 确保 Element Plus 分页组件可能异步触发的 change 事件在 isResetting 仍为 true 时执行完毕,
// 从而被 handlePageChange 中的判断拦截。
setTimeout(() => {
isResetting.value = false;
}, 0);
await fetchHandovers();
};
/**
* 重置
*/
const handleReset = () => {
handoverSamples.value = [];
handoverItems.value = [];
handovers.value = [];
total.value = 0;
queryRequest.value.current = 1;
queryRequest.value.size = 20;
queryRequest.value.outerApplyNo = "";
queryRequest.value.sjdwName = "";
queryRequest.value.acceptType = "";
queryRequest.value.assayGroupId = null;
queryRequest.value.acceptDateRange = { beginDate: "", endDate: "" };
// 重要:使用 setTimeout(0) 将 isResetting 恢复延迟至宏任务,
// 确保 Element Plus 分页组件可能异步触发的 change 事件在 isResetting 仍为 true 时执行完毕,
// 从而被 handlePageChange 中的判断拦截。
setTimeout(() => {
isResetting.value = false;
}, 0);
};
/**
* 分页改变
*/
const handlePageChange = async (currentPage: number, pageSize: number) => {
// 若正处于重置流程,忽略分页变化触发的查询
if (isResetting.value) return;
queryRequest.value.current = currentPage;
queryRequest.value.size = pageSize;
await fetchHandovers();
};
/**
* 打印
*/
const handlePrint = () => {
if (printTemplateMode.value === "DYNAMIC") {
if (selectedDynamicTemplateId.value) {
openDynamicPrintDrawer();
} else {
ElMessage.warning("请选择动态模板");
}
} else {
// 原有静态打印逻辑
// 打开打印抽屉
openPrintDrawer();
}
};
// 动态打印处理
const handleDynamicPrint = (printInfo: any) => {
// 处理动态模板打印
console.log("动态模板打印:", printInfo);
// 可以在这里添加额外的打印逻辑
};
/**
* 格式化状态
*/
const formatterStatus = (row: HandoverResponse) => {
switch (row.status) {
case HandoverStatus.UNSUBMITTED:
return "未提交";
case HandoverStatus.TO_BE_RECEIVED:
return "待接样";
case HandoverStatus.REJECTED:
return "被退回";
case HandoverStatus.TO_BE_WAREHOUSED:
return "待留样入库";
case HandoverStatus.TO_BE_ASSIGNED:
return "已接样";
case HandoverStatus.ASSIGN_FINISHED:
return "分派完成";
default: {
return "未知";
}
}
};
/**
* 关闭打印抽屉
*/
const closePrintDrawer = () => {
drawerVisible.value = false;
};
/**
* 关闭动态打印抽屉
*/
const closeDynamicPrintDrawer = () => {
dynamicDrawerVisible.value = false;
};
// ========== 监听(Watch) ==========
watch(
() => currentHandover.value,
async (newVal) => {
if (!newVal) {
handoverSamples.value = [];
handoverItems.value = [];
return;
}
await fetchHandoverSampAndItem(newVal.id!);
}
);
// ========== 生命周期(Lifecycle) ==========
onMounted(async () => {
// 并发请求,请求相互独立,获取模块名称、检验组别列表
const [moduleNameResult, userRoleResult] = await Promise.allSettled([
moduleStore.getModuleName(ModuleType.HANDOVER_QUERY),
fetchAssayGroups()
]);
if (moduleNameResult.status === "fulfilled") {
moduleName.value = moduleNameResult.value;
} else {
console.error("获取模块名称失败:", moduleNameResult.reason);
}
if (userRoleResult.status === "rejected") {
console.error("获取检验组别列表失败:", userRoleResult.reason);
}
// 重置
handleReset();
// loadDynamicTemplates();
});
</script>
<template>
<LayoutBasePage>
<template #header>
<!-- 页面标题 -->
<CommonPageTitle :title="moduleName ?? `交接查询`" />
<!-- 操作栏 -->
<LayoutActionBar>
<!--
两行三组,宽度用 inline style 精确控制:
- 两行三列按固定像素对齐(列宽 260 / 360 / 360)
- inline style 可穿透 scoped,直达 BasePreventReClickButton 内部的 el-button
- 具体数值推导见各组注释
-->
<div class="header-form">
<!-- 第一行:查询条件 -->
<el-form :model="queryRequest" :inline="true" :label-width="90">
<!-- 组1:受理编号(label 90 + input 170 = 260) -->
<el-form-item label="受理编号:" style="width: 260px; margin-right: 12px">
<el-input v-model="queryRequest.outerApplyNo" clearable style="width: 170px" />
</el-form-item>
<!-- 组2:受理日期(label 90 + date 270 = 360) -->
<el-form-item label="受理日期:" style="width: 360px; margin-right: 12px">
<el-date-picker
v-model="dateRangeValue"
style="width: 270px"
type="daterange"
start-placeholder="开始日期"
range-separator="至"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD">
</el-date-picker>
</el-form-item>
<!-- 组3:检测组别(label 90 + select 270 = 360) -->
<el-form-item label="检测组别:" style="width: 360px">
<el-select
v-model="queryRequest.assayGroupId"
placeholder="请选择"
clearable
filterable
style="width: 270px">
<el-option v-for="item in assayGroupOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<!-- 第二行:操作按钮 -->
<el-form :inline="true" :label-width="90" style="margin-top: 8px">
<!-- 组1:重置 + 查询(重置 78 + 间距 12 + 查询 170 = 260) -->
<el-form-item :label-width="0" style="width: 260px; margin-right: 12px">
<BasePreventReClickButton
:style="{ width: '78px', minWidth: '0', paddingLeft: '0', paddingRight: '0' }"
type="primary"
plain
@click="handleReset"
>重置</BasePreventReClickButton
>
<BasePreventReClickButton
:style="{ width: '170px', marginLeft: '12px' }"
type="primary"
plain
:delay="500"
@click="handleQuery"
>查询</BasePreventReClickButton
>
</el-form-item>
<!-- 组2:打印模板(label 90 + select 270 = 360) -->
<el-form-item label="打印模板:" style="width: 360px; margin-right: 12px">
<el-select
v-model="printTemplateValue"
placeholder="请选择"
clearable
filterable
allow-create
style="width: 270px">
<el-option label="交接单(单样多项)" :value="HandoverTemplate.SINGLE_SAMPLE"></el-option>
<el-option label="交接单(多样多项)" :value="HandoverTemplate.MULTIPLE_SAMPLE"></el-option>
</el-select>
<!-- 动态模板选择 -->
<el-select
v-if="printTemplateMode === 'DYNAMIC'"
v-model="selectedDynamicTemplateId"
placeholder="选择动态模板"
style="width: 270px">
<el-option
v-for="template in dynamicTemplates"
:key="template.id"
:label="template.templateName"
:value="template.id" />
</el-select>
</el-form-item>
<!-- 组3:打印(按钮 = 360px,撑满整列) -->
<el-form-item :label-width="0" style="width: 360px">
<BasePreventReClickButton
:style="{ width: '360px' }"
type="primary"
size="default"
:disabled="buttonPrintDisabled"
plain
@click="handlePrint"
>打印</BasePreventReClickButton
>
</el-form-item>
</el-form>
</div>
</LayoutActionBar>
</template>
<template #main>
<el-container class="main-container" ref="mainContainerRef">
<!-- ⭐ 高度由 mainTableHeight 控制(响应式绑定) -->
<div class="main-main" :style="{ height: `${mainTableHeight}px` }">
<!-- 交接表格 -->
<el-table
ref="handoverTableRef"
:data="handovers"
v-loading="handoverTableLoading"
:border="true"
highlight-current-row
style="width: 100%; height: 100%"
@row-click="handleRowClick"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="40" />
<el-table-column
prop="outerApplyNo"
label="受理编号"
width="120"
header-align="center"
fixed="left"
sortable
show-overflow-tooltip />
<el-table-column
prop="acceptDate"
label="受理日期"
width="120"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="acceptTypeName"
label="受理类别"
min-width="150"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="sjdwName"
label="受检单位"
min-width="300"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="assignerName"
label="派样人"
width="100"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="assignTime"
label="派样时间"
width="165"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="assayGroupName"
label="检测组别"
min-width="150"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="receiverName"
label="接样人"
width="100"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="receiveTime"
label="接样时间"
width="165"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="handoverDeadline"
label="交接限期"
width="110"
header-align="center"
sortable
show-overflow-tooltip />
<el-table-column
prop="status"
label="状态"
width="100"
header-align="center"
align="center"
fixed="right"
:formatter="formatterStatus"
sortable
show-overflow-tooltip />
</el-table>
</div>
<!-- 拖拽分割线(上下分割,调整主区表格高度) -->
<!-- min: 1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82 -->
<BaseResizer
v-model="mainTableHeight"
direction="vertical"
variant="block"
:min="82"
:max="calcMaxTableHeight"
:default-size="DEFAULT_MAIN_TABLE_HEIGHT" />
<el-footer class="main-footer">
<el-container class="main-footer-container" ref="footerContainerRef" v-loading="isDetailLoading">
<el-main class="main-footer-main">
<!-- 交接样品表格 -->
<CommonApplySampleTable
:apply-samples="handoverSamples"
:accept-type="acceptType"
:operation-command="CommonOperationCommand.VIEW" />
</el-main>
<!-- 拖拽分割线(左右分割,调整右侧项目表格宽度) -->
<BaseResizer
v-model="asideItemTableWidth"
direction="horizontal"
variant="block"
:min="100"
:max="calcMaxAsideItemTableWidth"
:default-size="DEFAULT_ASIDE_ITEM_TABLE_WIDTH" />
<el-aside class="main-footer-aside" :style="{ width: `${asideItemTableWidth}px` }">
<!-- 交接项目表格 -->
<HandoverItemBriefDisplayTable :tableData="handoverItems" />
</el-aside>
</el-container>
</el-footer>
</el-container>
</template>
<template #footer>
<!-- 分页 -->
<el-pagination
:total="total"
:page-sizes="[20, 50, 100, 200, 500]"
v-model:page-size="queryRequest.size"
v-model:current-page="queryRequest.current"
background
layout="total, sizes, prev, pager, next, jumper"
@change="handlePageChange" />
</template>
</LayoutBasePage>
<!-- 打印抽屉,用于打印交接单 -->
<PrintDrawer
:print-visible="drawerVisible"
:print-type="PrintType.HANDOVER"
:print-selection="handoverSelection"
:print-template="printTemplateValue"
@close-print-drawer="closePrintDrawer" />
<!-- 动态打印抽屉 -->
<PrintDynamicDrawer
v-if="printTemplateMode === `DYNAMIC`"
:visible="dynamicDrawerVisible"
:print-data="handoverSelection"
:print-type="PrintType.HANDOVER"
:print-template="selectedDynamicTemplateId!"
@close="closeDynamicPrintDrawer"
@print="handleDynamicPrint" />
</template>
<style scoped lang="scss">
// 查询/操作栏容器:两行布局,宽度由 inline style 控制,这里只负责上下间距
.header-form {
// 两行紧贴,只留极小间距
.el-form {
margin: 0 !important;
padding: 0;
}
// 压缩 el-form-item 自带的下边距(EP 默认有 18px)------这是"隔太开"的元凶
:deep(.el-form-item) {
margin-bottom: 4px; // 从 18px 压到 4px,让两行贴近
}
// 两行之间只留 4px,几乎贴在一起
// .el-form + .el-form 是 CSS 相邻兄弟选择器:A + B ------ 紧跟在 A 后面的第一个 B
// "紧跟在另一个 .el-form 后面的 .el-form" ------ 也就是第二个开始的 el-form(第一行之后的每一行)。
.el-form + .el-form {
margin-top: 4px !important;
}
}
.main-container {
height: 100%;
padding: 0px;
margin: 0px;
.main-main {
padding: 0px;
margin: 0px;
// 高度由 JS(mainTableHeight)精确控制,通过 :style 绑定
flex-shrink: 0; // 高度由 JS 精确控制,不允许被 flex 压缩,否则用户拖拽无效
overflow: hidden; // ⭐ 自身不滚动,让内部组件(表格)自己滚动
}
.main-footer {
flex: 1; // ⭐ 占据剩余空间,避免空白(主表格高度变了,footer 跟着变)
// ⚠️ 与 JS 常量 MIN_MAIN_FOOTER_HEIGHT 保持一致,改动时两处同步
min-height: 82px; // ⭐ flex item 默认 min-height: auto,会阻止 flex: 1 正确收缩
overflow: hidden; // ⭐ 自身不滚动,让内部组件自己滚动
height: auto; // 显式声明高度由 flex / 内容决定,不依赖 el-footer 默认的 60px(当前 flex: 1 下不生效,作为布局变化时的兜底)
padding: 0px;
margin: 0px;
.main-footer-container {
height: 100%;
padding: 0px;
margin: 0px;
.main-footer-main {
padding: 0px;
margin: 0px;
// ⚠️ 与 JS 常量 MIN_MAIN_FOOTER_MAIN_WIDTH 保持一致,改动时两处同步
min-width: 200px;
}
.main-footer-aside {
// 宽度由 JS(asideItemTableWidth)精确控制,通过 :style 绑定
flex-shrink: 0; // 宽度由 JS 精确控制,不允许被 flex 压缩,否则用户拖拽无效
}
}
}
}
</style>