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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titlediv 上下拖拽 resize 实战/title link relstylesheet href./resize.css / /head body div classdrawer__wrap iddrawerWrap !-- 上方可拖拽区域 -- div classtop__info idtopInfo !-- 拖拽手柄透明负责接收鼠标事件 -- div classresize__bar idresizeBar/div !-- 视觉分割线 -- div classresize__line idresizeLine/div !-- 内容层绝对定位不受手柄影响 -- div classresize__save div classtree__info左侧目录/div div classnode__info div classdetail__title右侧标题/div div classdetail__info右侧详情内容可以放模型输出/div /div /div /div !-- 下方固定区域 -- div classbottom__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 CSSflex 布局 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拖动时能看到styleheight: 320px这样的内联样式实时变化。选中.resize__bar它的height也同步变化。这说明 JS 在正确更新两个元素的高度。4.2 把拖拽面板接到模型请求上拖拽骨架跑通后你可以把.node__info里的内容换成真实的模型输出。比如做一个可调高度的对话窗口上方是对话历史下方是输入框。请求时用fetch调 TaoToken 的 APIasync 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 一样顺手”。