给页面加拖拽分割线:五步曲
假设现在有个没有分割的页面,你要给它加一条拖拽分割线。
分割线可以上下分割,也可以左右分割,下面每一步都同时说明两种情况。
命名约定:变量名要跟方向匹配——
- 上下分割 → 用 HEIGHT 系列(如 fixedHeight、DEFAULT_FIXED_HEIGHT、MIN_ADAPTIVE_HEIGHT)
- 左右分割 → 用 WIDTH 系列(如 fixedWidth、DEFAULT_FIXED_WIDTH、MIN_ADAPTIVE_WIDTH)
第 1 步:检查容器 + 确定位置和方向
① 先搞清「容器」是什么
分割线要夹在两个区域之间。这两个区域和分割线,必须装在同一个「容器」里。
容器 = 一个 DOM 元素(如 、
为什么要有容器?
hook 后面要拿这个容器的尺寸来算「固定区域最大能拖多大」——容器找不到,就不知道能拖到哪。所以必须先确定哪个元素当容器。
② 检查容器是否适配
检查容器,确认它只装了两个待分割的区域:
← 容器固定区域自适应区域
为什么? hook 算「固定区域最大能拖多大」的公式是:
max = 容器尺寸 - 分割线尺寸 - 自适应区域最小尺寸
容器里如果混进操作栏,容器尺寸会多算一块,算出来的 max 就偏大 → 拖到极限时自适应区域被挤爆(表格少一行、底部/侧边边框被裁)。
修法:操作栏移到 #header 插槽,容器里只留两个区域。
③ 确定分割线的位置和方向
| 问题 | 举例 |
|---|---|
| 分割线夹在哪两个区域之间? | 表格和详情之间 |
| 上下分割还是左右分割? | 上下分割 → direction=”vertical”;左右分割 → direction=”horizontal” |
| 有哪个区域用 v-show? | 有 → 第 3 步要一起处理 |
哪一个区域尺寸写死?
| 方向 | 固定区域 | 自适应区域 |
|---|---|---|
| 上下分割 | 上方区域写死 | 下方区域自适应 |
| 左右分割 | 左侧区域写死 | 右侧区域自适应 |
为什么是左 / 上? 因为拖分割线时:
- 上下分割:鼠标往下拖,分割线下移,上方区域被撑大 → 上方区域尺寸跟着鼠标变 → 跟手
- 左右分割:鼠标往右拖,分割线右移,左侧区域被撑大 → 左侧区域尺寸跟着鼠标变 → 跟手
这样鼠标往哪拖、分割线往哪移,手感自然。
第 2 步:声明拖拽所需的变量和约束
① 让 hook 知道容器在哪 —— 容器 ref
import { ref, type ComponentPublicInstance } from "vue";
// ========== 模板实例引用(Template Refs) ==========
/**
* 容器实例
* - 此容器只包含【固定区域 + 分割线 + 自适应区域】三件套。
* - 这个变量名 containerRef 就是模板容器里 ref="containerRef" 要写的字符串。
*/
const containerRef = ref(null);
② 常量
上下分割时:
// ========== 常量 ========== // ---------- 固定区域 ---------- /** * 固定区域默认高度(px) * - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值) */ const DEFAULT_FIXED_HEIGHT = 400; /** * 固定区域最小高度(px) * - 保证固定区域始终可用(比如:1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82) */ const MIN_FIXED_HEIGHT = 82; // ---------- 自适应区域 ---------- /** * 自适应区域最小高度(px) * - 作为固定区域拖拽上限的计算依据,保证自适应区域始终可用(比如:1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82) * - ⚠️ 与 CSS 里自适应区域的 min-height 保持一致,改动时两处同步 */ const MIN_ADAPTIVE_HEIGHT = 82;
左右分割时:
// ========== 常量 ========== // ---------- 固定区域 ---------- /** * 固定区域默认宽度(px) * - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值) */ const DEFAULT_FIXED_WIDTH = 400; /** * 固定区域最小宽度(px) * - 保证固定区域始终可用(比如:1 列 × 列宽(120)+ 边框,取值 200) */ const MIN_FIXED_WIDTH = 200; // ---------- 自适应区域 ---------- /** * 自适应区域最小宽度(px) * - 作为固定区域拖拽上限的计算依据,保证自适应区域始终可用(比如:1 列 × 列宽(120)+ 边框,取值 200) * - ⚠️ 与 CSS 里自适应区域的 min-width 保持一致,改动时两处同步 */ const MIN_ADAPTIVE_WIDTH = 200;
| 常量 | 含义 | 对应 hook 配置 | 与 CSS 的关联 |
|---|---|---|---|
| DEFAULT_FIXED_HEIGHT / DEFAULT_FIXED_WIDTH | 固定区域默认尺寸 | defaultSize | 无 |
| MIN_FIXED_HEIGHT / MIN_FIXED_WIDTH | 固定区域能压到多小 | minSize | 无 |
| MIN_ADAPTIVE_HEIGHT / MIN_ADAPTIVE_WIDTH | 自适应区域要留多少 | reserveSize | ⚠️ 与自适应区域的 min-height / min-width 同步 |
③ hook —— 告诉 hook 容器在哪
上下分割时:
import { useResizableRegion } from "@/hooks";
// ========== 组合式函数(Composables) ==========
/**
* 固定区域高度(px),可拖拽,带 localStorage 持久化
*/
const { size: fixedHeight, getMax: calcMaxFixedHeight } = useResizableRegion({
direction: "vertical",
storageKey: "xxx-fixed-height", // ⚠️ 每页必须唯一
defaultSize: DEFAULT_FIXED_HEIGHT,
minSize: MIN_FIXED_HEIGHT,
reserveSize: MIN_ADAPTIVE_HEIGHT,
getContainer: () => containerRef.value
});
左右分割时:
import { useResizableRegion } from "@/hooks";
// ========== 组合式函数(Composables) ==========
/**
* 固定区域宽度(px),可拖拽,带 localStorage 持久化
*/
const { size: fixedWidth, getMax: calcMaxFixedWidth } = useResizableRegion({
direction: "horizontal",
storageKey: "xxx-fixed-width", // ⚠️ 每页必须唯一
defaultSize: DEFAULT_FIXED_WIDTH,
minSize: MIN_FIXED_WIDTH,
reserveSize: MIN_ADAPTIVE_WIDTH,
getContainer: () => containerRef.value
});
useResizableRegion 配置项:
| 属性 | 作用 | 怎么写 |
|---|---|---|
| direction | 分割方向 | vertical:水平分割线(上下拖拽),调整高度 horizontal:垂直分割线(左右拖拽),调整宽度 |
| storageKey | 持久化键 | 建议加模块前缀,避免与其他页面冲突 如:report-prepare-fixed-height |
| defaultSize | 默认尺寸(px) | 传常量,如 DEFAULT_FIXED_HEIGHT / DEFAULT_FIXED_WIDTH |
| minSize | 固定区域的最小尺寸(px) | 传常量,如 MIN_FIXED_HEIGHT / MIN_FIXED_WIDTH |
| reserveSize | 自适应区域 的最小预留尺寸(px) 作为动态上限的计算依据 | 传常量,如 MIN_ADAPTIVE_HEIGHT / MIN_ADAPTIVE_WIDTH |
| getContainer | 返回第 2 步 ① 那个容器 | containerRef.value |
hook 返回值:
| 返回值 | 类型 | 用途 |
|---|---|---|
| size | Ref | 给模板 v-model 和 :style 用 |
| getMax | () => number | 给模板 :max 用 |
第 3 步:增加分割组件BaseResizer
先 import 组件:
import { BaseResizer } from "@/components";
① 容器绑定 ref(新增)
⚠️ 原模板一般没有 ref,这个 ref 是加分割线时新增的。
把第 2 步声明的 containerRef,绑定到容器的根元素上:
......
- 加在容器根元素上,就是第 1 步确定的那个容器
ref="containerRef"字符串必须和第 2 步的变量名一字不差- 名字不一致 → hook 拿不到容器尺寸 → 拖拽失效
-
不要为了绑定 ref 额外套一层
div,直接加在原来的容器上
② 固定区域加 :style、中间插 BaseResizer
上下分割时:
固定区域内容 自适应区域内容
左右分割时:
固定区域内容 自适应区域内容
两种情况对照表:
| 差异点 | 上下分割 | 左右分割 |
|---|---|---|
| :style 绑的属性 | height | width |
| BaseResizer.direction | “vertical” | “horizontal” |
| 固定区域位置 | 上方 | 左侧 |
BaseResizer 六个属性:
| 属性 | 作用 | 怎么写 |
|---|---|---|
| v-model | 双向绑定的尺寸值 | 绑固定区域的尺寸(fixedHeight 或 fixedWidth) |
| direction | 分割线方向 | vertical:水平分割线(上下拖拽),调整高度 horizontal:垂直分割线(左右拖拽),调整宽度 |
| variant | 分割线形态 | line:只显示细线(默认),干净、不抢焦点 block:整块色带(淡色块 + 中线),明显的“分界区域” |
| :min | 拖动下限 | 传常量,如 MIN_FIXED_HEIGHT / MIN_FIXED_WIDTH |
| :max | 拖动上限 | 传函数,来自 hook 的 getMax |
| :default-size | 双击恢复值 | 传常量,如 DEFAULT_FIXED_HEIGHT / DEFAULT_FIXED_WIDTH |
③ 有 v-show 时的追加处理
如果某一区域用 v-show 控制显示(比如自适应区域),隐藏时它 display: none 不占空间,但固定区域尺寸还是老值 → 留空白,分割线悬空。
修法:固定区域撑满、分割线一起隐藏:
上下分割时:
... ...
左右分割时:
... ...
两个要点:
- 固定区域的
:style里加三元判断——显示时用fixedHeight/fixedWidth,隐藏时用'100%'撑满 - 分割线加
v-show,跟自适应区域同步
第 4 步:修改 CSS
上下分割时:
// ========== 容器(固定区域 + 分割线 + 自适应区域) ==========
.container {
height: 100%;
padding: 0px;
margin: 0px;
// ---------- 固定区域(高度由 JS 控制,通过 :style 绑定) ----------
.fixed-region {
flex: none; // ⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
padding: 0px;
margin: 0px;
}
// ---------- 自适应区域(自动占满剩余空间) ----------
.adaptive-region {
flex: 1; // ⭐ 占满剩余空间,避免空白
// ⚠️ 与 JS 常量 MIN_ADAPTIVE_HEIGHT 保持一致,改动时两处同步
min-height: 82px;
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
height: auto; // 显式声明高度由 flex / 内容决定,覆盖默认值,当前 flex: 1 下不生效,作为布局变化时的兜底
padding: 0px;
margin: 0px;
}
}
左右分割时:
// ========== 容器(固定区域 + 分割线 + 自适应区域) ==========
.container {
display: flex; // ⭐ 左右分割必须显式 flex 布局
flex-direction: row; // ⭐ 横向排列
height: 100%;
width: 100%;
padding: 0px;
margin: 0px;
// ---------- 固定区域(宽度由 JS 控制,通过 :style 绑定) ----------
.fixed-region {
flex: none; // ⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
padding: 0px;
margin: 0px;
}
// ---------- 自适应区域(自动占满剩余空间) ----------
.adaptive-region {
flex: 1; // ⭐ 占满剩余空间,避免空白
// ⚠️ 与 JS 常量 MIN_ADAPTIVE_WIDTH 保持一致,改动时两处同步
min-width: 200px;
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
width: auto; // 显式声明,当前 flex: 1 下不生效,作为布局变化时的兜底
padding: 0px;
margin: 0px;
}
}
两种情况对照表:
| 差异点 | 上下分割 | 左右分割 |
|---|---|---|
| 容器是否需要 flex 显式声明 | div 必选 display: flex; flex-direction: column; el-container 可选 (el-container 默认就是 flex column) | 必须 display: flex; flex-direction: row; |
| 固定区域 flex: none 保护 | 高度不被压缩 | 宽度不被压缩 |
| 自适应区域 flex: 1 | 占满剩余高度 | 占满剩余宽度 |
| 自适应区域 min-* | min-height | min-width |
| 自适应区域显式 height/width: auto | height: auto | width: auto |
两个关键点:
- 固定区域 flex: none:⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
- 自适应区域 min-height / min-width:与 JS 的 MIN_ADAPTIVE_HEIGHT / MIN_ADAPTIVE_WIDTH 数值保持一致
⚠️ 如果原来的 CSS 用了 height: 50% / width: 50% 之类固定百分比,要改掉,换成 flex: 1 + height/width: auto + min-*。
第 5 步:回归测试
| 测试项 | 预期 |
|---|---|
| 拖动分割线到极限 | 鼠标往哪拖,分割线往哪移;两侧区域都不会被挤爆 |
| 缩放浏览器窗口 | 拖拽值自动收敛,不溢出 |
| 空数据 / 满数据 | 两种场景下表格都正常显示 |
| 某区域 v-show 切换 | 显示时正常拖动;隐藏时固定区域撑满、分割线隐藏、无留白 |
完整示例(上下分割)
import { BaseResizer } from "@/components";
import { useResizableRegion } from "@/hooks";
import { ref, type ComponentPublicInstance } from "vue";
// ========== 常量 ==========
// ---------- 固定区域 ----------
/**
* 固定区域默认高度(px)
* - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值)
*/
const DEFAULT_FIXED_HEIGHT = 400;
/**
* 固定区域最小高度(px)
* - 保证固定区域始终可用(1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82)
*/
const MIN_FIXED_HEIGHT = 82;
// ---------- 自适应区域 ----------
/**
* 自适应区域最小高度(px)
* - ⚠️ 与 CSS 里自适应区域的 min-height 保持一致,改动时两处同步
*/
const MIN_ADAPTIVE_HEIGHT = 82;
// ========== 组合式函数(Composables) ==========
/**
* 固定区域高度(px),可拖拽,带 localStorage 持久化
*/
const { size: fixedHeight, getMax: calcMaxFixedHeight } = useResizableRegion({
direction: "vertical",
storageKey: "xxx-fixed-height", // ⚠️ 每页必须唯一
defaultSize: DEFAULT_FIXED_HEIGHT,
minSize: MIN_FIXED_HEIGHT,
reserveSize: MIN_ADAPTIVE_HEIGHT,
getContainer: () => containerRef.value
});
// ========== 模板实例引用(Template Refs) ==========
/**
* 容器实例
* - 此容器只包含【固定区域 + 分割线 + 自适应区域】三件套。
* - 这个变量名 containerRef 就是模板容器里 ref="containerRef" 要写的字符串。
*/
const containerRef = ref(null);
固定区域内容
自适应区域内容
// ========== 容器(固定区域 + 分割线 + 自适应区域) ==========
.container {
display: flex; // ⭐ div 必须显式 flex 布局
flex-direction: column; // ⭐ 纵向排列
height: 100%;
padding: 0px;
margin: 0px;
// ---------- 固定区域(高度由 JS 控制,通过 :style 绑定) ----------
.fixed-region {
flex: none; // ⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
padding: 0px;
margin: 0px;
}
// ---------- 自适应区域(自动占满剩余空间) ----------
.adaptive-region {
flex: 1; // ⭐ 占满剩余空间,避免空白
// ⚠️ 与 JS 常量 MIN_ADAPTIVE_HEIGHT 保持一致,改动时两处同步
min-height: 82px;
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
height: auto; // 显式声明高度由 flex / 内容决定,覆盖默认值,当前 flex: 1 下不生效,作为布局变化时的兜底
padding: 0px;
margin: 0px;
}
}
完整示例(左右分割)
import { BaseResizer } from "@/components";
import { useResizableRegion } from "@/hooks";
import { ref, type ComponentPublicInstance } from "vue";
// ========== 常量 ==========
// ---------- 固定区域 ----------
/**
* 固定区域默认宽度(px)
* - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值)
*/
const DEFAULT_FIXED_WIDTH = 400;
/**
* 固定区域最小宽度(px)
* - 保证固定区域始终可用(比如:1 列 × 列宽(120)+ 边框,取值 200)
*/
const MIN_FIXED_WIDTH = 200;
// ---------- 自适应区域 ----------
/**
* 自适应区域最小宽度(px)
* - ⚠️ 与 CSS 里自适应区域的 min-width 保持一致,改动时两处同步
*/
const MIN_ADAPTIVE_WIDTH = 200;
// ========== 组合式函数(Composables) ==========
/**
* 固定区域宽度(px),可拖拽,带 localStorage 持久化
*/
const { size: fixedWidth, getMax: calcMaxFixedWidth } = useResizableRegion({
direction: "horizontal",
storageKey: "xxx-fixed-width", // ⚠️ 每页必须唯一
defaultSize: DEFAULT_FIXED_WIDTH,
minSize: MIN_FIXED_WIDTH,
reserveSize: MIN_ADAPTIVE_WIDTH,
getContainer: () => containerRef.value
});
// ========== 模板实例引用(Template Refs) ==========
/**
* 容器实例
* - 此容器只包含【固定区域 + 分割线 + 自适应区域】三件套。
* - 这个变量名 containerRef 就是模板容器里 ref="containerRef" 要写的字符串。
*/
const containerRef = ref(null);
固定区域内容
自适应区域内容
// ========== 容器(固定区域 + 分割线 + 自适应区域) ==========
.container {
display: flex; // ⭐ 左右分割必须显式 flex 布局
flex-direction: row; // ⭐ 横向排列
height: 100%;
width: 100%;
padding: 0px;
margin: 0px;
// ---------- 固定区域(宽度由 JS 控制,通过 :style 绑定) ----------
.fixed-region {
flex: none; // ⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
padding: 0px;
margin: 0px;
}
// ---------- 自适应区域(自动占满剩余空间) ----------
.adaptive-region {
flex: 1; // ⭐ 占满剩余空间,避免空白
// ⚠️ 与 JS 常量 MIN_ADAPTIVE_WIDTH 保持一致,改动时两处同步
min-width: 200px;
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
width: auto; // 显式声明,当前 flex: 1 下不生效,作为布局变化时的兜底
padding: 0px;
margin: 0px;
}
}
五步曲回顾
| 步骤 | 做什么 | 关键点 |
|---|---|---|
| 1 | 检查容器 + 确定位置和方向 | 容器只含待分割的两个区域,不混操作栏; 上下分割固定上方、左右分割固定左侧; 确认有无 v-show |
| 2 | 声明拖拽所需的变量和约束 | 模板里要用的变量全在这产出; 上下分割用 HEIGHT 系列、左右分割用 WIDTH 系列; containerRef 变量名后面模板要一字不差; storageKey 每页唯一 |
| 3 | 增加分割组件 BaseResizer | 容器新增 ref=”containerRef”(名字与第 2 步一致); 固定区域加 :style;中间插组件; 有 v-show 要一起处理 |
| 4 | 修改 CSS | 固定区域 flex: none; 自适应区域 flex: 1 + min-height/min-width + height/width: auto |
| 5 | 回归测试 | 拖到极限、窗口缩放、空/满数据、v-show 切换 |
最常踩的五个坑
| 坑 | 现象 | 修法 |
|---|---|---|
| 容器里混了操作栏 | 拖到极限时对面「少一行」 | 操作栏移到 #header(第 1 步就要查) |
| 漏写或写错 ref=”containerRef” | 拖拽完全没反应,hook 找不到容器 | 模板 ref 字符串和第 2 步变量名一字不差(第 3 步 ①) |
| 自适应区域原来是 height: 50% / width: 50%,只删不写 | 拖拽完全无效 | 改成 flex: 1 + min-height/min-width + height/width: auto(第 4 步) |
| 某区域 v-show 隐藏 | 留空白、分割线悬空 | 固定区域 :style 三元 + 分割线 v-show(第 3 步 ③) |
| JS 常量与 CSS 值不同步 | 拖到极限后错位 | 两处一起改 |
总结
- 检查容器是否适配(只放两个待分割的区域,不混操作栏);
- 声明拖拽所需的变量和约束(上下分割用 HEIGHT 系列、左右分割用 WIDTH 系列);
- 增加分割组件 BaseResizer(容器新增 ref=”containerRef”、固定区域加 :style、中间插组件、v-show 一起处理);
- 修改 CSS(固定区域 flex: none、自适应区域 flex: 1 + min-height/min-width + height/width: auto);
- 回归测试覆盖拖到极限、窗口缩放、v-show 切换。
严格按五步曲给「报告编制」加分割线
修改前的代码
/**
* 报告编制
*/
defineOptions({
name: "ReportPrepare"
});
import {
AssayTaskDisplayTable,
BasePreventReClickButton,
CommonPageTitle,
LayoutActionBar,
LayoutBasePage,
PrintDrawer,
SingleQueryOperation
} from "@/components";
import type { ReportResponse } from "@/openapi/types.gen";
import { useModuleStore } from "@/stores";
import type { PageParam } from "@/types";
import { ModuleType, PrintOperationType, PrintType, ReportDataCommand, ReportOperationCommand } from "@/types";
import { emitter } from "@/utils";
import { onMounted, onUnmounted, ref } from "vue";
import { ReportTable } from "./components";
// ========== 状态管理(Store) ==========
const moduleStore = useModuleStore();
// ========== 响应式状态(Ref / Reactive) ==========
// 模块名称
const moduleName = ref(null);
// 数据指令:待编制、待提交、已提交、被退回,默认选项是待编制
const dataCommand = ref(ReportDataCommand.TO_BE_PREPARED);
// 报告选集
const reportTableSelection = ref([]);
// 检验结果显示标识
const resultTableVisible = ref(false);
// 按钮编辑状态,默认都是不可编辑
const buttonPrepareDisabled = ref(true);
const buttonSubmitDisabled = ref(true);
const buttonClearDisabled = ref(true);
const buttonWithdrawDisabled = ref(true);
const buttonPreviewDisabled = ref(true);
// 抽屉显示标识
const drawerVisible = ref(false);
// 分页参数
const pageObj = ref({
current: 1,
size: 200
});
// 记录总数
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 isPrepare = ref(false);
// ========== 模板实例引用(Template Refs) ==========
// 报告表格实例
const reportTableRef = ref<InstanceType | null>(null);
// ========== 业务辅助方法 ==========
// 控制按钮编辑状态
function buttonDisabled(tableSelection: ReportResponse[]) {
// 编制,待编制状态可以操作
buttonPrepareDisabled.value =
tableSelection.length === 0 ||
dataCommand.value === ReportDataCommand.TO_BE_SUBMITTED ||
dataCommand.value === ReportDataCommand.SUBMITTED ||
dataCommand.value === ReportDataCommand.REJECTED;
// 提交,待提交/被退回状态可以操作
buttonSubmitDisabled.value =
tableSelection.length === 0 ||
dataCommand.value === ReportDataCommand.TO_BE_PREPARED ||
dataCommand.value === ReportDataCommand.SUBMITTED;
// 清空,等同于提交
buttonClearDisabled.value = buttonSubmitDisabled.value;
// 撤回,已提交状态可以操作
buttonWithdrawDisabled.value =
tableSelection.length === 0 ||
dataCommand.value === ReportDataCommand.TO_BE_PREPARED ||
dataCommand.value === ReportDataCommand.TO_BE_SUBMITTED ||
dataCommand.value === ReportDataCommand.REJECTED;
// 查看,有选集可以操作
buttonPreviewDisabled.value = tableSelection.length === 0;
reportTableSelection.value = tableSelection;
}
/**
* 打开打印抽屉
*/
const openPrintDrawer = () => {
drawerVisible.value = true;
};
// ========== 事件处理 ==========
/**
* 更新报告(编制、提交、撤回、清空)
*/
const handleOperate = async (command: ReportOperationCommand) => {
// 编制(合并)
if (command === ReportOperationCommand.PREPARE_PREPARE) {
isPrepare.value = true;
handlePreview();
} else {
await reportTableRef.value?.updateReport(command);
}
};
/**
* 查看
*/
const handlePreview = () => {
// 打开打印抽屉
openPrintDrawer();
};
/**
* 刷新
*/
const handleRefresh = async () => {
await reportTableRef.value?.getReport();
};
/**
* 显示/隐藏检验结果
*/
const handleShowCheckResultChange = (value: boolean) => {
resultTableVisible.value = value;
if (resultTableVisible.value) {
emitter.emit("transmit-outerApplyNo", reportTableRef.value?.tableSelectedRow?.outerApplyNo);
}
};
/**
* 分页改变
*/
const handlePageChange = async (currentPage: number, pageSize: number) => {
// 若正处于重置流程,忽略分页变化触发的查询
if (isResetting.value) return;
pageObj.value.current = currentPage;
pageObj.value.size = pageSize;
emitter.emit("transmit-pageObj", pageObj.value);
// 刷新
await handleRefresh();
};
/**
* 关闭打印抽屉
*/
const closePrintDrawer = async () => {
// 只有待编制状态时,点击【编制(合并)】,才更新报告状态为已编制(待提交)
if (dataCommand.value === ReportDataCommand.TO_BE_PREPARED && isPrepare.value) {
await reportTableRef.value?.updateReport(ReportOperationCommand.PREPARE_PREPARE);
}
isPrepare.value = false;
drawerVisible.value = false;
};
// ========== 生命周期(Lifecycle) ==========
onMounted(async () => {
moduleName.value = await moduleStore.getModuleName(ModuleType.REPORT_PREPARE);
emitter.emit("transmit-pageObj", pageObj.value);
emitter.on("transmit-total", (event: unknown) => {
total.value = event as number;
});
emitter.on("transmit-reset-page", () => {
isResetting.value = true;
pageObj.value.current = 1;
// 宏任务延迟恢复,确保分页组件的微任务 change 已被拦截
setTimeout(() => {
isResetting.value = false;
}, 0);
});
});
onUnmounted(() => {
emitter.off("transmit-total");
emitter.off("transmit-reset-page");
});
合并提交清空撤回查看刷新待编制待提交已提交被退回
.header-e {
margin: 0px;
margin-right: 12px;
}
.main-container {
height: 100%;
padding: 0px;
margin: 0px;
.main-main {
padding: 0px;
margin: 0px;
}
.main-footer {
height: 50%;
padding: 0px;
margin: 0px;
}
}
第 1 步:检查容器 + 确定位置和方向
① 容器是什么
... ← 报告表格 ... ← 检验结果表格
容器 = 。
② 检查容器是否适配
容器里只有两个区域(报告表格 + 检验结果表格),操作栏在 #header 插槽里,没混进容器。✅ 适配。
③ 确定分割线的位置和方向
| 问题 | 答案 |
|---|---|
| 夹在哪两个区域之间? | 报告表格(上)和检验结果表格(下)之间 |
| 上下分割还是左右分割? | 上下分割 |
| 有哪个区域用 v-show? | 下区 v-show=”resultTableVisible” → 第 3 步要一起处理 |
哪一个区域尺寸写死?
- 上下分割 → 上方区域写死(报告表格),下方区域自适应(检验结果表格)
- 为什么是上? 鼠标往下拖,分割线下移,上方区域被撑大 → 上方区域尺寸跟着鼠标变 → 跟手
第 2 步:声明拖拽所需的变量和约束
① 让 hook 知道容器在哪 —— 容器 ref
import { ref, type ComponentPublicInstance } from "vue";
// ========== 模板实例引用(Template Refs) ==========
/**
* 容器实例
* - 此容器只包含【固定区域 + 分割线 + 自适应区域】三件套。
* - 这个变量名 containerRef 就是模板容器里 ref="containerRef" 要写的字符串。
*/
const containerRef = ref(null);
② 常量(上下分割 → HEIGHT 系列)
// ========== 常量 ========== // ---------- 固定区域 ---------- /** * 固定区域默认高度(px) * - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值) */ const DEFAULT_FIXED_HEIGHT = 400; /** * 固定区域最小高度(px) * - 保证固定区域始终可用(1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82) */ const MIN_FIXED_HEIGHT = 82; // ---------- 自适应区域 ---------- /** * 自适应区域最小高度(px) * - ⚠️ 与 CSS 里自适应区域的 min-height 保持一致,改动时两处同步 */ const MIN_ADAPTIVE_HEIGHT = 82;
③ hook —— 告诉 hook 容器在哪
import { useResizableRegion } from "@/hooks";
// ========== 组合式函数(Composables) ==========
/**
* 固定区域高度(px),可拖拽,带 localStorage 持久化
*/
const { size: fixedHeight, getMax: calcMaxFixedHeight } = useResizableRegion({
direction: "vertical",
storageKey: "report-prepare-fixed-height", // ⚠️ 每页必须唯一
defaultSize: DEFAULT_FIXED_HEIGHT,
minSize: MIN_FIXED_HEIGHT,
reserveSize: MIN_ADAPTIVE_HEIGHT,
getContainer: () => containerRef.value
});
第 3 步:增加分割组件BaseResizer
先 import 组件
import { BaseResizer } from "@/components";
① 容器绑定 ref(新增)
② 固定区域加 :style、中间插 BaseResizer、处理 v-show
本页面控制下区显示的变量是 resultTableVisible,对应五步曲里的 showAdaptiveRegion。
第 4 步:修改 CSS
// ========== 容器(固定区域 + 分割线 + 自适应区域) ==========
.main-container {
height: 100%;
padding: 0px;
margin: 0px;
// ---------- 固定区域(高度由 JS 控制,通过 :style 绑定) ----------
.main-main {
flex: none; // ⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
padding: 0px;
margin: 0px;
}
// ---------- 自适应区域(自动占满剩余空间) ----------
.main-footer {
flex: 1; // ⭐ 占满剩余空间,避免空白
// ⚠️ 与 JS 常量 MIN_ADAPTIVE_HEIGHT 保持一致,改动时两处同步
min-height: 82px;
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
height: auto; // 显式声明高度由 flex / 内容决定,覆盖默认值,当前 flex: 1 下不生效,作为布局变化时的兜底
padding: 0px;
margin: 0px;
}
}
改动点:原 .main-footer { height: 50% } 删掉,换成 flex: 1 + min-height: 82px + height: auto。
第 5 步:回归测试
| 测试项 | 预期 |
|---|---|
| 拖动分割线到极限 | 鼠标往下拖上区变大;往上拖上区变小;两侧都不被挤爆 |
| resultTableVisible = true | 正常拖动 |
| resultTableVisible = false | 上区撑满、分割线隐藏、无留白 |
| 缩放浏览器窗口 | 拖拽值自动收敛,不溢出 |
修改后的代码
/**
* 报告编制
*/
defineOptions({
name: "ReportPrepare"
});
import {
AssayTaskDisplayTable,
BasePreventReClickButton,
BaseResizer,
CommonPageTitle,
LayoutActionBar,
LayoutBasePage,
PrintDrawer,
SingleQueryOperation
} from "@/components";
import { useResizableRegion } from "@/hooks";
import type { ReportResponse } from "@/openapi/types.gen";
import { useModuleStore } from "@/stores";
import type { PageParam } from "@/types";
import { ModuleType, PrintOperationType, PrintType, ReportDataCommand, ReportOperationCommand } from "@/types";
import { emitter } from "@/utils";
import { onMounted, onUnmounted, ref, type ComponentPublicInstance } from "vue";
import { ReportTable } from "./components";
// ========== 常量 ==========
// ---------- 固定区域 ----------
/**
* 固定区域默认高度(px)
* - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值)
*/
const DEFAULT_FIXED_HEIGHT = 400;
/**
* 固定区域最小高度(px)
* - 保证固定区域始终可用(1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82)
*/
const MIN_FIXED_HEIGHT = 82;
// ---------- 自适应区域 ----------
/**
* 自适应区域最小高度(px)
* - ⚠️ 与 CSS 里自适应区域的 min-height 保持一致,改动时两处同步
*/
const MIN_ADAPTIVE_HEIGHT = 82;
// ========== 状态管理(Store) ==========
const moduleStore = useModuleStore();
// ========== 响应式状态(Ref / Reactive) ==========
// 模块名称
const moduleName = ref(null);
// 数据指令:待编制、待提交、已提交、被退回,默认选项是待编制
const dataCommand = ref(ReportDataCommand.TO_BE_PREPARED);
// 报告选集
const reportTableSelection = ref([]);
// 检验结果显示标识
const resultTableVisible = ref(false);
// 按钮编辑状态,默认都是不可编辑
const buttonPrepareDisabled = ref(true);
const buttonSubmitDisabled = ref(true);
const buttonClearDisabled = ref(true);
const buttonWithdrawDisabled = ref(true);
const buttonPreviewDisabled = ref(true);
// 抽屉显示标识
const drawerVisible = ref(false);
// 分页参数
const pageObj = ref({
current: 1,
size: 200
});
// 记录总数
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 isPrepare = ref(false);
// ========== 模板实例引用(Template Refs) ==========
/**
* 容器实例
* - 此容器只包含【固定区域 + 分割线 + 自适应区域】三件套。
* - 这个变量名 containerRef 就是模板容器里 ref="containerRef" 要写的字符串。
*/
const containerRef = ref(null);
// 报告表格实例
const reportTableRef = ref<InstanceType | null>(null);
// ========== 组合式函数(Composables) ==========
/**
* 固定区域高度(px),可拖拽,带 localStorage 持久化
*/
const { size: fixedHeight, getMax: calcMaxFixedHeight } = useResizableRegion({
direction: "vertical",
storageKey: "report-prepare-fixed-height", // ⚠️ 每页必须唯一
defaultSize: DEFAULT_FIXED_HEIGHT,
minSize: MIN_FIXED_HEIGHT,
reserveSize: MIN_ADAPTIVE_HEIGHT,
getContainer: () => containerRef.value
});
// ========== 业务辅助方法 ==========
// 控制按钮编辑状态
function buttonDisabled(tableSelection: ReportResponse[]) {
// 编制,待编制状态可以操作
buttonPrepareDisabled.value =
tableSelection.length === 0 ||
dataCommand.value === ReportDataCommand.TO_BE_SUBMITTED ||
dataCommand.value === ReportDataCommand.SUBMITTED ||
dataCommand.value === ReportDataCommand.REJECTED;
// 提交,待提交/被退回状态可以操作
buttonSubmitDisabled.value =
tableSelection.length === 0 ||
dataCommand.value === ReportDataCommand.TO_BE_PREPARED ||
dataCommand.value === ReportDataCommand.SUBMITTED;
// 清空,等同于提交
buttonClearDisabled.value = buttonSubmitDisabled.value;
// 撤回,已提交状态可以操作
buttonWithdrawDisabled.value =
tableSelection.length === 0 ||
dataCommand.value === ReportDataCommand.TO_BE_PREPARED ||
dataCommand.value === ReportDataCommand.TO_BE_SUBMITTED ||
dataCommand.value === ReportDataCommand.REJECTED;
// 查看,有选集可以操作
buttonPreviewDisabled.value = tableSelection.length === 0;
reportTableSelection.value = tableSelection;
}
/**
* 打开打印抽屉
*/
const openPrintDrawer = () => {
drawerVisible.value = true;
};
// ========== 事件处理 ==========
/**
* 更新报告(编制、提交、撤回、清空)
*/
const handleOperate = async (command: ReportOperationCommand) => {
// 编制(合并)
if (command === ReportOperationCommand.PREPARE_PREPARE) {
isPrepare.value = true;
handlePreview();
} else {
await reportTableRef.value?.updateReport(command);
}
};
/**
* 查看
*/
const handlePreview = () => {
// 打开打印抽屉
openPrintDrawer();
};
/**
* 刷新
*/
const handleRefresh = async () => {
await reportTableRef.value?.getReport();
};
/**
* 显示/隐藏检验结果
*/
const handleShowCheckResultChange = (value: boolean) => {
resultTableVisible.value = value;
if (resultTableVisible.value) {
emitter.emit("transmit-outerApplyNo", reportTableRef.value?.tableSelectedRow?.outerApplyNo);
}
};
/**
* 分页改变
*/
const handlePageChange = async (currentPage: number, pageSize: number) => {
// 若正处于重置流程,忽略分页变化触发的查询
if (isResetting.value) return;
pageObj.value.current = currentPage;
pageObj.value.size = pageSize;
emitter.emit("transmit-pageObj", pageObj.value);
// 刷新
await handleRefresh();
};
/**
* 关闭打印抽屉
*/
const closePrintDrawer = async () => {
// 只有待编制状态时,点击【编制(合并)】,才更新报告状态为已编制(待提交)
if (dataCommand.value === ReportDataCommand.TO_BE_PREPARED && isPrepare.value) {
await reportTableRef.value?.updateReport(ReportOperationCommand.PREPARE_PREPARE);
}
isPrepare.value = false;
drawerVisible.value = false;
};
// ========== 生命周期(Lifecycle) ==========
onMounted(async () => {
moduleName.value = await moduleStore.getModuleName(ModuleType.REPORT_PREPARE);
emitter.emit("transmit-pageObj", pageObj.value);
emitter.on("transmit-total", (event: unknown) => {
total.value = event as number;
});
emitter.on("transmit-reset-page", () => {
isResetting.value = true;
pageObj.value.current = 1;
// 宏任务延迟恢复,确保分页组件的微任务 change 已被拦截
setTimeout(() => {
isResetting.value = false;
}, 0);
});
});
onUnmounted(() => {
emitter.off("transmit-total");
emitter.off("transmit-reset-page");
});
合并提交清空撤回查看刷新待编制待提交已提交被退回
.header-e {
margin: 0px;
margin-right: 12px;
}
// ========== 容器(固定区域 + 分割线 + 自适应区域) ==========
.main-container {
height: 100%;
padding: 0px;
margin: 0px;
// ---------- 固定区域(高度由 JS 控制,通过 :style 绑定) ----------
.main-main {
flex: none; // ⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
padding: 0px;
margin: 0px;
}
// ---------- 自适应区域(自动占满剩余空间) ----------
.main-footer {
flex: 1; // ⭐ 占满剩余空间,避免空白
// ⚠️ 与 JS 常量 MIN_ADAPTIVE_HEIGHT 保持一致,改动时两处同步
min-height: 82px;
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
height: auto; // 显式声明高度由 flex / 内容决定,覆盖默认值,当前 flex: 1 下不生效,作为布局变化时的兜底
padding: 0px;
margin: 0px;
}
}
修改的内容
import { BaseResizer } from "@/components";
import { useResizableRegion } from "@/hooks";
import { ref, type ComponentPublicInstance } from "vue";
// ========== 常量 ==========
// ---------- 固定区域 ----------
/**
* 固定区域默认高度(px)
* - 用于 useResizableRegion 的 defaultSize(初始值 + 双击分割线恢复值)
*/
const DEFAULT_FIXED_HEIGHT = 400;
/**
* 固定区域最小高度(px)
* - 保证固定区域始终可用(1 行 × 行高(40)+ 表头(40)+ 边框(2)= 82)
*/
const MIN_FIXED_HEIGHT = 82;
// ---------- 自适应区域 ----------
/**
* 自适应区域最小高度(px)
* - ⚠️ 与 CSS 里自适应区域的 min-height 保持一致,改动时两处同步
*/
const MIN_ADAPTIVE_HEIGHT = 82;
// ========== 模板实例引用(Template Refs) ==========
/**
* 容器实例
* - 此容器只包含【固定区域 + 分割线 + 自适应区域】三件套。
* - 这个变量名 containerRef 就是模板容器里 ref="containerRef" 要写的字符串。
*/
const containerRef = ref(null);
// ========== 组合式函数(Composables) ==========
/**
* 固定区域高度(px),可拖拽,带 localStorage 持久化
*/
const { size: fixedHeight, getMax: calcMaxFixedHeight } = useResizableRegion({
direction: "vertical",
storageKey: "report-prepare-fixed-height", // ⚠️ 每页必须唯一
defaultSize: DEFAULT_FIXED_HEIGHT,
minSize: MIN_FIXED_HEIGHT,
reserveSize: MIN_ADAPTIVE_HEIGHT,
getContainer: () => containerRef.value
});
// ========== 容器(固定区域 + 分割线 + 自适应区域) ==========
.main-container {
height: 100%;
padding: 0px;
margin: 0px;
// ---------- 固定区域(高度由 JS 控制,通过 :style 绑定) ----------
.main-main {
flex: none; // ⭐ 一次性关掉 grow + shrink,尺寸完全由 :style 说了算(否则拖拽异常)
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
padding: 0px;
margin: 0px;
}
// ---------- 自适应区域(自动占满剩余空间) ----------
.main-footer {
flex: 1; // ⭐ 占满剩余空间,避免空白
// ⚠️ 与 JS 常量 MIN_ADAPTIVE_HEIGHT 保持一致,改动时两处同步
min-height: 82px;
overflow: hidden; // ⭐ 自身不滚动,让内部组件(如表格)自己滚动
height: auto; // 显式声明高度由 flex / 内容决定,覆盖默认值,当前 flex: 1 下不生效,作为布局变化时的兜底
padding: 0px;
margin: 0px;
}
}
验证结果
严格按五步走完,全部通过:
| 步骤 | 做了什么 | 关键点 |
|---|---|---|
| 1 | 检查容器 + 定方向 | 容器 main-container 只含两个区域 ✅;上下分割、固定上方;v-show 变量 = resultTableVisible |
| 2 | 声明变量和约束 | containerRef + DEFAULT_FIXED_HEIGHT / MIN_FIXED_HEIGHT / MIN_ADAPTIVE_HEIGHT + useResizableRegion |
| 3 | 加 BaseResizer | import;容器加 ref=”containerRef”;上区加 :style;中间插组件;v-show 三处同步 |
| 4 | 修改 CSS | .main-main 加 flex: none + overflow: hidden;.main-footer 从 height: 50% 改成 flex: 1 + min-height + height: auto |
| 5 | 回归测试 | 4 项含 resultTableVisible 切换 |
关键映射:文档里的 showAdaptiveRegion → 本页面的 resultTableVisible。
应用效果



到此这篇关于Vue3 给页面加拖拽分割线的实现步骤的文章就介绍到这了,更多相关Vue3 给页面加拖拽分割线内容请搜索IT俱乐部以前的文章或继续浏览下面的相关文章希望大家以后多多支持IT俱乐部!
相关文章
-
antd vue 表格rowSelection选择框功能的使用方式
这篇文章主要介绍了antd vue 表格rowSelection选择框功能的使用方式,具有很好的参考价值,希望对大家有所帮助。以上为个人经验,希望能给大家一个参考,也希望大家多多支持IT俱乐部。2022-12-12
-
vue中ts无法识别引入的vue文件,提示找不到xxx.vue模块的解决
这篇文章主要介绍了vue中ts无法识别引入的vue文件,提示找不到xxx.vue模块的解决方案,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教2022-09-09









最新评论