1. 从后台面板的“拖不动”说起
div 上下拖拽 resize 这件事,看起来只是把鼠标放到一条线上往下拉,但真动手写,问题一个接一个:鼠标移到分割线上不变形、拖到一半内容被盖住、松手后高度回弹、flex 容器里子元素把父级撑爆。我最近在做一个后台配置面板,左侧是树形目录,右侧是详情区,底部还要留一块日志输出,用户希望能像 IDE 那样上下拖动中间区域的高度。搜了一圈,发现大多数方案要么依赖第三方库,要么用resize: vertical这种 CSS3 属性凑合,但resize只在元素右下角生成一个小三角,根本没法让整条分割线都可拖。
这篇就聚焦纯前端实现:用 flex 布局搭骨架,用cursor: ns-resize给出上下拖拽的视觉反馈,用mousedown / mousemove / mouseup三件套绑定事件,再配合min-height / max-height做高度约束。适合正在写后台面板、代码编辑器、日志查看器、聊天窗口这类需要动态分配上下空间的开发者。读完你能直接复制一套 HTML/CSS/JS 骨架,在浏览器里跑起来,并且知道每个参数为什么这么设。
核心检索词先摆出来:div 上下拖拽、resize、css cursor、flex 布局、鼠标事件绑定、高度约束。这几个词会贯穿全文,每一步都对应一个可验证的结果。
2. 动手前先把 TaoToken 的 Key 和文档准备好
如果你只是本地写个 demo,其实不需要任何外部服务。但如果你打算把拖拽面板接到真实的大模型接口上,比如做一个可调高度的对话窗口、代码补全面板,那就需要一个稳定的 API 入口。我平时用 TaoToken 来统一管理模型调用,它的 API 地址是 https://taotoken.net/api,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。注意 API 地址后面不加任何 UTM 参数,直接请求即可。
接入前你需要先拿到 API Key。打开 https://taotoken.net/api-keys ,登录后创建一个新的 Key,复制保存。这个 Key 就是你后续在fetch或 SDK 里填的凭证。如果你用的是 Claude Code 这类编码工具,可以参考 https://taotoken.net/doc 里的接入说明,把 base URL 指向https://taotoken.net/api,模型名按文档里列出的填。
提示:Key 只显示一次,建议存到环境变量里,不要硬编码进前端代码。前端 demo 可以用一个后端代理转发,避免 Key 暴露。
准备好 Key 之后,你就可以把拖拽面板的“内容区”换成真实的模型对话或代码输出。拖拽本身是纯前端行为,和 API 无关,但两者结合才是完整场景。下面先专注拖拽骨架,最后再把它接到请求上。
3. 可复制的 HTML/CSS/JS 骨架
3.1 HTML 结构:三层分离
结构上我分成三层:外层 flex 容器、上方可拖拽区、下方固定区。上方区里再分内容层和拖拽手柄层。内容层用position: absolute独立出来,手柄层用透明 div 趴在后面控制高度。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>div 上下拖拽 resize 实战</title> <link rel="stylesheet" href="./resize.css" /> </head> <body> <div class="drawer__wrap" id="drawerWrap"> <!-- 上方可拖拽区域 --> <div class="top__info" id="topInfo"> <!-- 拖拽手柄:透明,负责接收鼠标事件 --> <div class="resize__bar" id="resizeBar"></div> <!-- 视觉分割线 --> <div class="resize__line" id="resizeLine"></div> <!-- 内容层:绝对定位,不受手柄影响 --> <div class="resize__save"> <div class="tree__info">左侧目录</div> <div class="node__info"> <div class="detail__title">右侧标题</div> <div class="detail__info">右侧详情内容,可以放模型输出</div> </div> </div> </div> <!-- 下方固定区域 --> <div class="bottom__info">下方日志区</div> </div> <script src="./resize.js"></script> </body> </html>这里的关键是.resize__bar和.resize__save的层级关系。.resize__bar在前,.resize__save在后,配合position: absolute和z-index,让内容层盖住手柄层,但手柄层仍然能通过::-webkit-scrollbar放大拖拽区域。
3.2 CSS:flex 布局 + cursor + 透明手柄
CSS 部分我拆成几块讲。外层.drawer__wrap用display: flex; flex-direction: column;让上下两块垂直排列。上方.top__info设position: relative; overflow: hidden;,这样内容层绝对定位时不会溢出。
* { box-sizing: border-box; margin: 0; padding: 0; } body { height: 100vh; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } .drawer__wrap { display: flex; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; } .top__info { width: 100%; position: relative; overflow: hidden; display: flex; justify-content: center; /* 初始高度,JS 会动态覆盖 */ height: 230px; min-height: 80px; max-height: 70vh; } .resize__save { position: absolute; top: 0; right: 0; bottom: 10px; /* 留出 10px 给拖拽线 */ left: 0; overflow-x: hidden; overflow-y: auto; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: center; z-index: 2; } .tree__info { flex: 0 0 35%; overflow: auto; padding: 8px; } .node__info { flex: 0 0 65%; padding: 8px; border-left: 1px solid #e5e7eb; overflow: auto; } .resize__bar { width: 300px; height: 230px; /* 与 top__info 初始高度一致 */ resize: vertical; cursor: ns-resize; opacity: 0; overflow: scroll; min-height: 80px; max-height: 70vh; position: relative; z-index: 1; } .resize__bar::-webkit-scrollbar { width: 300px; height: inherit; } .resize__line { position: absolute; right: 45%; left: 44%; bottom: 0; height: 3px; background: #d8d8d8; cursor: ns-resize; z-index: 3; transition: background 0.15s ease; } .resize__bar:hover ~ .resize__line, .resize__bar:active ~ .resize__line { background: #6b7280; } .bottom__info { flex: 1; width: 100%; padding: 16px; overflow: auto; background: #f9fafb; }几个参数解释一下。.resize__bar的height必须和.top__info的初始高度一致,否则拖拽起点会跳。opacity: 0让它视觉隐藏,但resize: vertical和overflow: scroll仍然生效。::-webkit-scrollbar的width: 300px是为了把滚动条区域放大到和手柄同宽,这样鼠标在整条线上都能触发拖拽,而不是只有右下角那个小三角。
.resize__line用left: 44%; right: 45%;让它居中偏左一点,视觉上像一条短分割线。cursor: ns-resize是上下拖拽的标准光标,用户一看就知道这里能拖。
3.3 JS:鼠标事件绑定与高度约束
纯 CSS 的resize属性只能拖右下角,所以真正好用的方案还是 JS 事件。核心逻辑:mousedown记录起始 Y 和起始高度,mousemove计算差值并更新高度,mouseup解绑事件。
(function () { const wrap = document.getElementById('drawerWrap'); const topInfo = document.getElementById('topInfo'); const bar = document.getElementById('resizeBar'); const line = document.getElementById('resizeLine'); const MIN_HEIGHT = 80; const MAX_HEIGHT = () => window.innerHeight * 0.7; let startY = 0; let startHeight = 0; let dragging = false; function onMouseDown(e) { dragging = true; startY = e.clientY; startHeight = topInfo.getBoundingClientRect().height; document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); e.preventDefault(); } function onMouseMove(e) { if (!dragging) return; const delta = e.clientY - startY; let next = startHeight + delta; next = Math.max(MIN_HEIGHT, Math.min(next, MAX_HEIGHT())); topInfo.style.height = next + 'px'; bar.style.height = next + 'px'; } function onMouseUp() { dragging = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } bar.addEventListener('mousedown', onMouseDown); line.addEventListener('mousedown', onMouseDown); // 窗口尺寸变化时重新约束 window.addEventListener('resize', () => { const current = topInfo.getBoundingClientRect().height; if (current > MAX_HEIGHT()) { topInfo.style.height = MAX_HEIGHT() + 'px'; bar.style.height = MAX_HEIGHT() + 'px'; } }); })();这里有几个细节值得说。第一,startHeight用getBoundingClientRect().height而不是offsetHeight,前者是浮点数,拖拽更平滑。第二,MAX_HEIGHT用函数而不是常量,因为窗口大小可能变。第三,mousemove和mouseup绑在document上而不是bar上,这样鼠标拖出分割线区域也不会丢失事件。第四,e.preventDefault()防止拖拽时选中文本。
注意:如果你在 Vue/React 里用,记得在组件卸载时移除
document上的监听,否则会内存泄漏。React 里可以用useEffect的 cleanup 函数处理。
4. 验证请求与成功结果
4.1 浏览器里怎么验证拖拽生效
把上面三个文件保存到同一个目录,用浏览器打开index.html。你会看到上方是左右分栏的内容区,下方是灰色日志区,中间有一条短横线。把鼠标移到横线上,光标变成上下箭头,按住往下拖,上方区域变高,下方区域自动缩小;往上拖则相反。拖到最小 80px 或最大 70vh 时停住,不会继续。
打开 DevTools 的 Elements 面板,选中.top__info,拖动时能看到style="height: 320px"这样的内联样式实时变化。选中.resize__bar,它的height也同步变化。这说明 JS 在正确更新两个元素的高度。
4.2 把拖拽面板接到模型请求上
拖拽骨架跑通后,你可以把.node__info里的内容换成真实的模型输出。比如做一个可调高度的对话窗口,上方是对话历史,下方是输入框。请求时用fetch调 TaoToken 的 API:
async function askModel(prompt) { const res = await fetch('https://taotoken.net/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + process.env.TAOTOKEN_API_KEY }, body: JSON.stringify({ model: 'claude-3-5-sonnet', messages: [{ role: 'user', content: prompt }] }) }); const data = await res.json(); return data.choices[0].message.content; }注意Authorization头里的 Key 不要写死在前端。生产环境建议用后端代理,前端只调自己的接口。如果你只是本地测试,可以把 Key 放在.env里,用构建工具注入。
请求成功后,把返回内容渲染到.node__info里,拖拽调整高度时内容区会自动滚动,不会溢出。这就是拖拽面板和模型接口结合后的完整效果。
5. 本篇常见错排查
5.1 鼠标移到分割线不变光标
最常见的原因是.resize__line被其他元素盖住了。检查.resize__save的z-index是不是比.resize__line高。如果.resize__save的bottom: 10px没留出来,它会盖住底部的线。另外确认.resize__line有cursor: ns-resize,并且pointer-events没有被设成none。
5.2 拖拽时内容被遮挡或溢出
.top__info必须设overflow: hidden,否则内容层绝对定位后会超出父级。.resize__save的bottom要留出和分割线等高的距离,比如bottom: 10px,这样内容不会盖住拖拽线。如果内容还是溢出,检查.resize__save有没有overflow-y: auto。
5.3 拖拽到边界后回弹或跳变
回弹通常是因为min-height和max-height只设在了 CSS 上,JS 更新时没有同步约束。我的做法是在onMouseMove里用Math.max和Math.min双重限制,并且同时更新topInfo和bar的高度。如果只更新一个,另一个会在下次拖拽时用旧值计算,导致跳变。
5.4 flex 容器里高度不生效
在 flex 子项上设height有时会被flex-grow或flex-shrink覆盖。解决办法是给.top__info设flex: 0 0 auto,让它不参与伸缩,高度完全由 JS 控制。.bottom__info设flex: 1,自动占满剩余空间。这样拖拽时只有上方高度变,下方自动补位。
5.5 移动端触摸拖拽无效
上面的代码只绑了鼠标事件。移动端需要加touchstart / touchmove / touchend,并且用e.touches[0].clientY取坐标。另外要加touch-action: none到.resize__line上,防止浏览器把拖拽识别成页面滚动。如果你主要做桌面端后台,可以先不管移动端,但知道这个坑在哪。
6. 把拖拽面板用到你的真实项目里
拖拽骨架本身不复杂,难的是和业务结合时不破坏布局。我的经验是:把.top__info的高度存到localStorage,刷新页面后恢复用户上次拖拽的位置,体验会好很多。代码就一行:
const saved = localStorage.getItem('panelHeight'); if (saved) { topInfo.style.height = saved + 'px'; bar.style.height = saved + 'px'; } // 在 onMouseUp 里保存 localStorage.setItem('panelHeight', topInfo.getBoundingClientRect().height);如果你要把这个面板接到模型对话或代码补全上,建议先用 https://taotoken.net/api-keys 创建一个专用 Key,再对照 https://taotoken.net/doc 里的接入文档配置 base URL。需要长期跑编码任务或 Agent 的话,可以看看 https://taotoken.net/coding-plan 里的方案,比按次调用更划算。想先验证模型输出效果,直接打开 https://taotoken.net/chat 试几句,确认返回格式再写进你的面板。
拖拽这件事,CSS 的resize属性只能算玩具,真正可控的还是 JS 事件加 flex 布局。把cursor: ns-resize和min-height / max-height这两个细节做到位,用户拖起来就会觉得“跟 IDE 一样顺手”。