Next.js 思维导图实战:从数据结构到协作的完整指南
简介这份NEXT.JS思维导图面向正在学习React服务端渲染与全栈开发的前端开发者尤其适合刚接触Next.js、需要快速梳理知识体系的初中级工程师。内容围绕框架核心概念展开涵盖应用创建命令、/app目录下routes、components、lib、ui、public、scripts等模块划分以及next.config.js配置要点同时整理了占位数据的含义与存放方式、Prisma配合TypeScript实现数据库模型类型同步、mockAPI等第三方服务的使用思路。样式部分则串联全局CSS、TailwindCSS实用类、CSS模块作用域隔离与clsx动态类名切换并延伸至next/font字体优化与Image组件图片处理。资源为1个PDF文件压缩包约846KB以思维导图形式呈现层级清晰、便于按主题检索与复习。已有105人学习适合作为日常查阅与面试前知识串联的轻量参考。1. 用 Next.js 做思维导图为什么我放弃了纯前端方案去年帮一个做在线教育的小团队重构知识梳理工具时我第一反应是「思维导图而已一个 canvas 库加 localStorage 就够了」。结果两周后翻车用户在同一台设备上打开两个标签页节点互相覆盖导出 PNG 时中文字体丢失最要命的是分享链接发出去对方看到的是一张空白画布。这三个问题逼着我重新选型最后落到 Next.js 上。思维导图这类应用的本质不是「画图」而是「一棵可持久化、可协作、可导出的树」——树的序列化、路由分享、服务端渲染首屏、字体资源加载这些恰好是 Next.js 的强项。这篇笔记写给已经会 React、想用 Next.js 把思维导图从 Demo 做到能给别人用的开发者从数据结构一路讲到导出和排错。2. 思维导图的数据结构选型为什么不是嵌套对象2.1 嵌套树、扁平表、图结构三选一大多数人画思维导图第一版代码会写成这样const root { id: root, text: 中心主题, children: [ { id: a, text: 分支一, children: [] }, { id: b, text: 分支二, children: [] }, ], };这个结构读起来直观但一旦要「拖动节点换父级」「跨分支复制」「撤销重做」嵌套对象就会让你痛不欲生——你得写一堆递归查找父节点、递归删除、递归插入的函数而且每次更新都要浅拷贝整棵树React 的 memo 直接失效。我一般会改成扁平表加 parentId 的写法// 扁平化存储所有节点放在一个 Record 里父子关系靠 parentId 和 children 数组维护 const nodes { root: { id: root, text: 中心主题, parentId: null, children: [a, b] }, a: { id: a, text: 分支一, parentId: root, children: [] }, b: { id: b, text: 分支二, parentId: root, children: [] }, }; const rootId root;逻辑说明每个节点只存自己的 id、文本、父 id 和子 id 列表节点之间不互相引用对象。这样移动一个节点只需要改三处旧父的 children 数组、新父的 children 数组、自己的 parentId。参数上children用数组而不是 Set是因为要保证渲染顺序稳定拖拽排序时直接 splice 即可。提示如果后面要做多人协作扁平表天然适合做 CRDT 或 OT 的增量同步嵌套树则要先拍平再同步多一层转换。2.2 用 Zustand 管理画布状态别用 Context思维导图的状态包括节点表、选中节点、缩放比例、拖拽中的临时坐标。用 React Context 的话任何一次拖拽都会让整棵树重渲染节点一多就卡。我一般用 Zustand把状态切成两块import { create } from zustand; // 节点数据与视图状态分离避免拖拽时触发节点列表重渲染 export const useMapStore create((set, get) ({ nodes: {}, rootId: null, selectedId: null, scale: 1, // 只更新选中态不碰 nodes select: (id) set({ selectedId: id }), // 批量更新节点调用方负责传入新对象 patchNodes: (patch) set((s) ({ nodes: { ...s.nodes, ...patch } })), moveNode: (id, newParentId, index) { const { nodes } get(); const node nodes[id]; if (!node || id newParentId) return; // 防止把父节点拖进自己的子孙里形成环 if (isDescendant(nodes, id, newParentId)) return; const next { ...nodes }; next[node.parentId] { ...next[node.parentId], children: next[node.parentId].children.filter((c) c ! id), }; next[newParentId] { ...next[newParentId], children: [ ...next[newParentId].children.slice(0, index), id, ...next[newParentId].children.slice(index), ], }; next[id] { ...node, parentId: newParentId }; set({ nodes: next }); }, })); // 判断 target 是否是 source 的子孙防止拖拽成环 function isDescendant(nodes, sourceId, targetId) { let cur targetId; while (cur) { if (cur sourceId) return true; cur nodes[cur]?.parentId; } return false; }逻辑说明moveNode里先做环检测这是思维导图拖拽最容易忽略的一步一旦成环递归渲染会直接爆栈。参数index是插入位置用 slice 拼接而不是 push保证拖到两个节点中间时顺序正确。patchNodes用对象展开而不是直接改是为了让 Zustand 的浅比较能感知变化。2.3 布局算法从中心向两侧展开的最小实现思维导图常见布局是「根节点居中子节点向右或左右分布」。第一版不用上 dagre 这种重库手写一个递归算 y 坐标的版本就够// 计算每个节点的 x/y向右布局同级节点垂直均分 function layout(nodes, rootId, { xGap 220, yGap 48 } {}) { const positions {}; let cursorY 0; function walk(id, depth) { const node nodes[id]; const children node.children; if (children.length 0) { // 叶子节点占用一个 yGap 的高度 positions[id] { x: depth * xGap, y: cursorY }; cursorY yGap; return positions[id].y; } const childYs children.map((cid) walk(cid, depth 1)); // 父节点 y 取子节点 y 的中位数 const y (childYs[0] childYs[childYs.length - 1]) / 2; positions[id] { x: depth * xGap, y }; return y; } walk(rootId, 0); return positions; }逻辑说明cursorY是全局游标只有叶子节点会推进它父节点取首尾子节点的中点这样树看起来是「居中」的。参数xGap控制层级间距yGap控制同级间距中文节点建议 yGap 不小于 44否则文字会挤在一起。这个算法的时间复杂度是 O(n)几千个节点也不会有性能问题。3. 在 Next.js 里把画布跑起来App Router 下的渲染与交互3.1 为什么画布组件必须挂 use clientNext.js 的 App Router 默认是服务端组件而思维导图的画布依赖鼠标事件、ResizeObserver、requestAnimationFrame这些在服务端都不存在。所以画布组件第一行必须是use client。但整页都标 client 会丢掉 SSR 的好处我的做法是拆成两层// app/map/[id]/page.js —— 服务端组件负责取数据和首屏骨架 import { getMapById } from /lib/map-store; import MapCanvas from ./MapCanvas; export default async function MapPage({ params }) { const map await getMapById(params.id); if (!map) return div导图不存在/div; // 只把序列化后的数据传给客户端组件 return MapCanvas initialNodes{map.nodes} rootId{map.rootId} /; }// app/map/[id]/MapCanvas.js —— 客户端组件负责交互 use client; import { useEffect, useRef } from react; import { useMapStore } from /store/map; export default function MapCanvas({ initialNodes, rootId }) { const svgRef useRef(null); const { nodes, patchNodes, scale } useMapStore(); // 首屏把服务端数据灌进 store只执行一次 useEffect(() { patchNodes(initialNodes); }, [initialNodes, patchNodes]); // 监听容器尺寸变化重新计算可视区域 useEffect(() { const el svgRef.current; if (!el) return; const ro new ResizeObserver(() { // 触发一次重渲染即可布局在 render 里算 el.dispatchEvent(new CustomEvent(canvas:resize)); }); ro.observe(el); return () ro.disconnect(); }, []); return ( svg ref{svgRef} width100% height100% g transform{scale(${scale})} {/* 节点与连线渲染省略 */} /g /svg ); }逻辑说明服务端组件负责取数据客户端组件负责交互initialNodes通过 props 序列化传递避免客户端再发一次请求。ResizeObserver用来处理侧边栏折叠、窗口缩放导致的画布尺寸变化。参数上scale放在 store 里而不是组件 state是为了让工具栏的缩放按钮和画布共享同一个值。3.2 用 SVG 还是 Canvas一张对比表说清维度SVGCanvas节点数量上限约 1000 个节点流畅上万节点仍可事件绑定每个节点直接 onClick需要手动做命中检测文字渲染原生支持可选中需 measureText 手动排版导出 PNG序列化后转图片简单直接 toDataURL无障碍天然支持需额外补 aria我的选择是节点数在 1000 以内用 SVG超过再考虑 Canvas 或虚拟化。思维导图的实际使用场景里超过 500 个节点的导图已经很难阅读所以 SVG 是更划算的起点。3.3 拖拽与缩放的三个必调参数拖拽用 pointer 事件而不是 mouse 事件因为要兼容触屏和手写笔// 拖拽开始时记录指针相对画布的偏移移动时换算成画布坐标 function onPointerDown(e, nodeId) { e.currentTarget.setPointerCapture(e.pointerId); const start { x: e.clientX, y: e.clientY }; const origin getNodePosition(nodeId); const onMove (ev) { const dx (ev.clientX - start.x) / scale; const dy (ev.clientY - start.y) / scale; setDragPreview({ id: nodeId, x: origin.x dx, y: origin.y dy }); }; const onUp () { window.removeEventListener(pointermove, onMove); window.removeEventListener(pointerup, onUp); commitDrop(nodeId); }; window.addEventListener(pointermove, onMove); window.addEventListener(pointerup, onUp); }逻辑说明setPointerCapture保证指针移出元素后事件仍然派发到该元素。除以scale是关键否则缩放后拖拽距离会和鼠标位移不一致这是新手最常见的「手感不对」来源。三个必调参数scale的缩放步长建议 0.1最小 0.3、最大 3拖拽阈值设 4px小于这个值视为点击而不是拖拽pointermove里要做节流用 requestAnimationFrame 包一层否则高频事件会让 React 每秒重渲染上百次。4. 持久化与分享把导图存下来、发出去4.1 用 Route Handler 做保存接口Next.js 的 Route Handler 可以直接写后端逻辑不需要另起服务// app/api/map/[id]/route.js import { NextResponse } from next/server; import { saveMap, getMapById } from /lib/map-store; export async function GET(_, { params }) { const map await getMapById(params.id); if (!map) return NextResponse.json({ error: not found }, { status: 404 }); return NextResponse.json(map); } export async function PUT(req, { params }) { const body await req.json(); // 只接受白名单字段避免客户端塞入任意结构 const payload { nodes: body.nodes ?? {}, rootId: body.rootId ?? null, updatedAt: Date.now(), }; await saveMap(params.id, payload); return NextResponse.json({ ok: true }); }逻辑说明GET 用于分享链接打开时拉取数据PUT 用于自动保存。参数上updatedAt用于后续做冲突检测如果两个标签页同时保存可以比较时间戳决定谁覆盖谁。白名单过滤是必须的否则客户端可以往数据库里写任意字段。4.2 自动保存的防抖与冲突处理自动保存不能每次改动都发请求我一般用 800ms 防抖加「保存中」状态锁// 防抖保存最后一次改动后 800ms 才发请求保存期间的新改动排队 let timer null; let saving false; let pending null; function scheduleSave(getState) { pending getState(); if (timer) clearTimeout(timer); timer setTimeout(async () { if (saving) return; // 正在保存等下一次调度 saving true; const snapshot pending; pending null; try { await fetch(/api/map/${mapId}, { method: PUT, headers: { Content-Type: application/json }, body: JSON.stringify(snapshot), }); } finally { saving false; if (pending) scheduleSave(() pending); // 保存期间又有改动补一次 } }, 800); }逻辑说明pending保存最新快照saving是互斥锁避免两个请求并发导致后发先至。参数 800ms 是手感和请求量的折中低于 500ms 请求太密高于 1500ms 用户关页面时容易丢数据。另外建议在beforeunload里用navigator.sendBeacon补一次同步保存。4.3 导出 PNG 时中文字体丢失怎么解SVG 导出 PNG 的常见做法是序列化 SVG 再画到 canvasasync function exportPng(svgEl, filename mindmap.png) { const clone svgEl.cloneNode(true); // 把外部字体样式内联进去否则 canvas 渲染时拿不到字体 const style document.createElement(style); style.textContent text { font-family: Noto Sans SC, PingFang SC, sans-serif; } ; clone.insertBefore(style, clone.firstChild); const xml new XMLSerializer().serializeToString(clone); const blob new Blob([xml], { type: image/svgxml;charsetutf-8 }); const url URL.createObjectURL(blob); const img new Image(); await new Promise((res, rej) { img.onload res; img.onerror rej; img.src url; }); const canvas document.createElement(canvas); const rect svgEl.getBoundingClientRect(); const dpr window.devicePixelRatio || 1; canvas.width rect.width * dpr; canvas.height rect.height * dpr; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); ctx.drawImage(img, 0, 0, rect.width, rect.height); URL.revokeObjectURL(url); const a document.createElement(a); a.download filename; a.href canvas.toDataURL(image/png); a.click(); }逻辑说明中文字体丢失的根因是 SVG 序列化后变成独立文档外部 CSS 不再生效所以要把 font-family 内联进style。devicePixelRatio是为了在高分屏上导出清晰图不加的话导出图会糊。参数上如果导图很大canvas 尺寸可能超过浏览器上限约 16384px需要分块绘制或降低 dpr。5. 避坑与排查那些让我加班到凌晨的问题5.1 现象拖拽节点后位置跳变松手瞬间弹回原位原因拖拽过程中用的是临时坐标dragPreview松手时commitDrop把节点写回 store但布局函数重新计算了所有节点位置临时坐标和布局坐标不一致。解决拖拽时只更新临时坐标松手后不要立刻重算布局而是把节点插入目标父级的 children 后用动画过渡到新布局位置或者干脆在拖拽期间冻结布局。5.2 现象分享链接打开是空白控制台报 hydration 错误原因服务端渲染出的节点顺序和客户端首次渲染不一致常见于用Math.random()生成节点 id 或Date.now()做 key。解决所有 id 在服务端生成并随数据下发客户端不要重新生成如果必须客户端生成用useId或把该部分包在useEffect里延迟渲染。5.3 现象节点多了以后输入框打字卡顿原因每次输入都触发整棵树重渲染且输入框是受控组件值存在全局 store 里。解决输入框改成非受控用defaultValue加onBlur提交或者把正在编辑的节点文本存在局部 state失焦时再写回 store。参数上输入防抖设 300ms配合startTransition标记为非紧急更新。5.4 现象导出 PNG 时连线错位原因SVG 里连线用的是path的d属性序列化时如果路径依赖了运行时计算的getBoundingClientRect克隆出来的节点没有真实尺寸路径就错了。解决连线路径只用节点数据里的 x/y 计算不要读 DOM 尺寸导出前先确保布局已经算完并写进了节点数据。5.5 现象两个标签页同时编辑后保存的覆盖了先保存的原因没有做版本控制PUT 直接覆盖。解决每个导图带一个version字段保存时带上版本号服务端比较版本不一致就返回 409客户端提示「内容已在别处更新」。这是最轻量的乐观锁比引入 CRDT 成本低得多。6. 进阶把思维导图做成可协作的实时画布单机版做完之后如果要做多人同时编辑最省力的路径不是自己写 WebSocket 协议而是把节点操作抽象成「命令」用广播的方式同步。每个操作形如{ type: move, id, newParentId, index, version }本地先应用再广播收到远端命令时如果 version 比自己新就应用否则丢弃。这套逻辑和前面扁平表结构是天然契合的因为每个命令只影响少数几个节点。验证协作是否正常我一般用两个浏览器窗口并排一个窗口拖节点另一个窗口观察是否在 200ms 内同步。如果延迟明显先查是不是每次操作都全量同步了整棵树——正确做法是只同步增量命令。另一个容易忽略的点是「正在输入」的状态A 在编辑节点文本时B 不应该看到半截文字所以文本编辑要等失焦或防抖结束后才广播中间态只留在本地。最后分享一个我踩过的坑早期我把整个 nodes 对象存进 Zustand 的 persist 中间件结果 localStorage 很快超过 5MB 上限页面直接白屏。后来改成只持久化「最近打开的导图 id」真正的数据走服务端本地只缓存一份只读快照用于离线展示。这个习惯我一直保留到现在——本地存储只放指针不放数据本身。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

Claude Code 模板体系:告别重复上下文,稳定 AI 编程

Claude Code 模板体系:告别重复上下文,稳定 AI 编程

用 Claude Code 小半年,我最大的感受是:大多数人把它当成一个随时可以对话的聊天窗口,问一句答一句;少数人把它当成一个需要调教的协作者,花时间把项目背景、代码规范、输出格式反复喂给它。我属于后者,而且…

2026/10/17 12:57:11 阅读更多 →
Qt上位机与STM32串口通信:从帧协议到LED控制的完整实现

Qt上位机与STM32串口通信:从帧协议到LED控制的完整实现

简介:面向Qt串口开发新手,这套资料围绕Qt上位机与STM32单片机之间的串口通信展开,覆盖数据收发、按钮控制LED与蜂鸣器等典型交互场景,可直接用于单片机入门、毕业设计或课程实验,尤其适合零基础学习者对照实践。压缩包…

2026/10/17 12:57:11 阅读更多 →
LLM推理性能评估核心指标详解:从TTFT到吞吐量的实战指南

LLM推理性能评估核心指标详解:从TTFT到吞吐量的实战指南

直接聊一个很多人踩过的坑:优化LLM推理服务时,手头全是数据,但真要判断“这次改动是变好了还是变差了”,看着那堆指标反而不知道看哪个。响应时间忽高忽低,问别人,有人甩一句“看TTFT”,有人说“…

2026/10/17 12:57:11 阅读更多 →

最新新闻

STM32红外收发底层驱动实战:从NEC协议到示波器级调试

STM32红外收发底层驱动实战:从NEC协议到示波器级调试

简介:本资源是一份面向51单片机初学者与嵌入式开发者的红外通信模块实战代码详解资料,聚焦红外发射与接收功能的底层实现,解决遥控、设备控制等典型应用场景中的信号编解码与硬件驱动问题。压缩包为单个36KB的PDF文档,内容涵盖红外…

2026/10/17 22:21:23 阅读更多 →
2026年AI大模型API聚合平台选型指南:TaoToken统一Key接入三类场景实测

2026年AI大模型API聚合平台选型指南:TaoToken统一Key接入三类场景实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 22:21:23 阅读更多 →
DeepSeek API调用避坑指南:鉴权、空响应与流式中断的实战解法

DeepSeek API调用避坑指南:鉴权、空响应与流式中断的实战解法

简介:本资源是一份面向Python开发者与AI初学者的DeepSeek API调用实战指南,聚焦API开发、AI交互与安全实践等核心能力培养,帮助具备基础编程经验的技术人员快速掌握大模型服务集成方法。文档以通俗易懂的“外卖小哥”类比贯穿全篇&#xff0c…

2026/10/17 22:21:23 阅读更多 →
本地知识库选型对比:Cherry Studio与AnythingLLM及嵌入模型效果实测

本地知识库选型对比:Cherry Studio与AnythingLLM及嵌入模型效果实测

简介:这份文档面向从事AI工具研究与开发的技术人员及企业IT负责人,围绕DeepSeek模型知识库场景,系统对比了Cherry Studio与AnythingLLM两款对话式AI工具的实际使用效果,并进一步评估了三种嵌入模型的表现差异。实验以deepseek-r1:…

2026/10/17 22:21:23 阅读更多 →
Kafka接入AI实战:从消息管道到智能数据管道

Kafka接入AI实战:从消息管道到智能数据管道

我这两年跟Kafka打交道的时间,比跟家人说话的时间都多。群里每天都在问“Kafka宕机怎么办”“消费延迟怎么查”,说实话,这些问题翻来覆去就那几个套路。但最近画风变了,大家开始问“Kafka能不能直接把消息丢给大模型处理”“AI能不…

2026/10/17 22:20:22 阅读更多 →
Vue3全栈源码实战:TypeScript类型系统如何撑起可维护链路

Vue3全栈源码实战:TypeScript类型系统如何撑起可维护链路

简介:这份原创源码资料面向希望深入掌握 TypeScript 类型系统并落地 Vue3 全栈项目的前端进阶开发者,围绕高级类型编程、装饰器、全局声明文件等核心知识点展开,同时提供一套 Vue3 TS Pinia 的完整基础项目,可用于工程化实践与项…

2026/10/17 22:20:22 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →