Vue3 可拖拽分栏布局:两种实现方式详解

可拖拽分栏布局:两种实现方式详解

基于 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                  ← 左右分割线
         └─ 右侧区块                     ← 自适应

两份代码唯一的差异集中在两点:

差异点HandoverQueryAssayReceive
HookuseResizableSize + 手写 calcMax + useClampOnResizeuseResizableRegion(内置一切)
左右分割绑谁右侧宽度左侧宽度

其余全部一致(操作栏都在 #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
监听容器尺寸变化,重新计算 maxuseClampOnResize
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 对比表

HandoverQueryAssayReceive
固定侧位置右侧左侧
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


五、实现方式对比总表

维度HandoverQueryAssayReceive
HookuseResizableSizeuseResizableRegion
max 计算手写 calcMaxXxxHeight()hook 内置
窗口 clamp手动挂 useClampOnResizehook 内置
容器取根手写 getElementFromRefhook 内部处理
首次默认值固定 px固定 px 或 defaultRatio
min 语义分散在 :mincalcMax集中: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_WIDTHDEFAULT_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

<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

<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>

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值