WeKan 工作区WorkspacesAll Boards 左侧菜单的无限层级看板文件夹树设计解析【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan导读本文围绕 WeKan 开源看板中「工作区Workspaces」功能展开它是一棵挂在 All Boards 页面左侧菜单里、用于收纳看板Boards与子工作区的无限层级文件夹树。你将掌握它的数据模型、URL 寻址方式、折叠记忆的三层持久化机制、基于 HTML5 拖拽的三种放置语义及其源码级实现原理以及为什么折叠箭头、拖拽槽位等细节要按文档中的方式设计——这些全部有对应的模块源码与自动化测试用例可查证。工作区是什么看板的文件夹挂在用户档案上的树在 WeKan 中工作区本质上是「All Boards 页面左侧菜单里存放看板的文件夹」一个工作区可以容纳看板每块看板被分配到且仅属于一个工作区一个工作区也可以容纳其他工作区子工作区嵌套深度不限整棵树是按用户独立维护的数据存放在用户文档的profile.boardWorkspacesTree字段上是一个普通的{ id, name, icon, iconColor, children }数组。这一点在 models/users.js 的 schema 声明中可以印证profile.boardWorkspacesTree被声明为type: Array其中的节点为{ id: String, name: String, children: Arraynode }并扩展出icon、iconColor等可选字段见 models/users.js。每个工作区都有自己的地址形如/allboards/workspaces/engineering/backend由它沿树向下的各层名字的 slug 拼接而成——相关设计见 The All Boards URLs。路由使用/allboards/:section?/:path*其中:path*是零或多个路径段因此工作区可以像它的树一样嵌套任意深度。从源码结构看profile.boardWorkspacesTree是一棵纯数据树服务端只负责整体读写所有「拖拽后树变成什么样」的规则都收敛在独立模块 models/lib/workspacesTree.js 中该模块头注释明确写着无 Meteor、无 DOM、无集合是一个可单测的纯函数模块并引用了本设计文档。一行row显示什么从阅读顺序到选中态一行工作区按阅读顺序依次是折叠箭头caret——仅当该工作区下还有子工作区时出现拖拽手柄drag handle——仅在开启「显示桌面拖拽手柄」时出现图标icon名称name⋯ 菜单按钮该工作区内的看板数量count。其中看板数量放在行尾与上方 Starred / Templates / Remaining 各分区的数量位置一致见 All Boards 文档中「What a workspace row shows」一节。选中态的填充色是主题强调色但填充范围只覆盖图标和名称即整个a.js-select-space锚点而不覆盖整行菜单按钮和数量停留在面板自身的浅灰背景上因此无论该行是否被选中它们的样式始终一致。这一选择来自一次真实回归——此前选中行的数量带一个浅色药丸形背景以增强对比结果在深色填充行上变成了白字配浅灰底恰好是用户刚点击想查看的那一行反而看不清数量。相关讨论与布局规则菜单与数量为flex: 0 0 auto、名称锚点为flex: 1且min-width: 0超长名称自动省略号见 All Boards。折叠工作区从箭头语义到三层记忆箭头的形态与排列拥有子工作区的工作区行首有折叠箭头展开时为向下fa-caret-down折叠时为向右fa-caret-righttooltip 文案与看板列表、左侧菜单自身一致Collapse / Uncollapse。这在 boardsList.jade 的workspaceTree递归模板中有对应实现if workspaceHasChildren分支渲染a.workspace-collapse-indicator.js-collapse-workspace否则渲染span.workspace-collapse-spacer占位。有两个关键设计点箭头在最前先于拖拽手柄——这样无论是否显示手柄整棵树的箭头都对齐在一列上没有子工作区的行放一个同宽度的占位符spacer——否则一行在刚获得第一个子项的瞬间会横向移位等于给了用户一个移动的瞄准目标。模板注释明确写道箭头位于行首、手柄之前使树的一列箭头在手柄开关两种状态下都能对齐见 boardsList.jade。键盘可达性无 href 的锚点箭头是一个没有href的锚点因此必须手工补齐按钮语义携带rolebutton、tabindex0并挂keydown处理器响应 Enter 与 Space——只能用鼠标打开的树一半读者都打不开。同时它的aria-label不仅要说明动作还要带上工作区自己的名字因为aria-label会替换元素自身的文本内容若一棵树的箭头都只念同样的两个词屏幕阅读器用户听到的将是一串无法区分的重复。这在 boardsList.js 中有直接证据Template.workspaceTree.events里同时存在click .js-collapse-workspace与keydown .js-collapse-workspace后者判断evt.key为 Enter / Space / Spacebar 后翻转折叠状态而workspaceCollapseLabel()助手用 TAPi18n 取 Collapse/Uncollapse 文案并拼接工作区名见 boardsList.js。只存折叠的不存展开的三层持久化默认是展开因此只持久化被折叠的工作区缺失的键即已展开——一棵五十个工作区的树若只折叠了两个就只存两个键。折叠状态与菜单里其他状态一样存在三层中优先级从高到低层载体作用Session 值collapsedWorkspace-workspaceId点击箭头时立即响应无需等待网络用户档案profile.collapsedWorkspacesmapworkspaceId - true已登录用户的持久化Cookiewekan-collapsed-workspaces未登录阅读者的持久化对应实现client/lib/utils.js 中的getWorkspaceCollapseState()/setWorkspaceCollapseState()先读 Session命中即返回否则读user.isWorkspaceCollapsed(workspaceId)未登录时回退到Users.getPublicCollapsedWorkspaces()的 cookie map写时先Session.set已登录则Meteor.call(setWorkspaceCollapsed, ...)未登录则写 cookiemodels/users.js 中的isWorkspaceCollapsed()map[workspaceId] true才是折叠——缺失键 展开这是从没碰过的工作区的正确默认值models/users.js 中的profile.collapsedWorkspacesschema注释同样写明只存折叠的以及Users.getPublicCollapsedWorkspaces/setPublicCollapsedWorkspace两个 cookie 辅助复用与折叠列表相同的readCookieMap/writeCookieMap帮助函数。为什么折叠也要校验 id 格式服务端方法setWorkspaceCollapsed(workspaceId, collapsed)见 server/models/users.js会把工作区 id 写进点号分隔的字段路径dotted field path而 id 所来源的树又是客户端写上去的——所以 id 必须先通过^[A-Za-z0-9_-]{1,64}$校验。一个携带.或$的 id 会寻址到别的字段甚至嵌套字段而不是这张 map 里的一个键。另一个细节展开unfold时用$unset删掉键而不是存false——因为这张 map 只记录哪些被折叠了。测试 tests/workspacesTree.test.cjs 明确断言了这一点服务端方法体中必须有$unset并且check(workspaceId, String)与check(collapsed, Boolean)两道参数校验。把一个工作区拖到另一个上落点决定语义三种落点三种含义拖拽的语义由指针落在行内的位置决定这是整棵拖拽树的规则核心落在行的含义上 1/4成为该行的上一个兄弟previous sibling下 1/4成为该行的下一个兄弟next sibling中间一半成为该行的最后一个子项last child——即子工作区中间区域是刻意做成最大目标的重新排序还可以通过瞄准相邻行的远边实现但嵌套只有这一个入口。这条规则在源码里被浓缩成一行可测试的算术。dropPosition(offsetY, height)位于 models/lib/workspacesTree.jsEDGE_FRACTION 0.25上下各四分之一为 before/after中间为 inside并且height或offsetY为 null/NaN/非正数时兜底返回 inside——因为Number(null)是 0一个完全合法的行顶偏移若先做Number()再判空缺失的偏移会被误读为一次故意的 before行尚未布局、高度为 0 时则宁可回答 inside 也不能除零。这些边界在 tests/workspacesTree.test.cjs 中都有对应的正反向用例高度 0、-1、NaN、undefined、null、字符串等。占位符是槽而不是线当指针悬停在某一行上时一个一行高的空槽会在该行上方、下方或拖入其中时缩进一格的该行下方打开——恰好是工作区将要出现的位置。它的样式是2px 虚线描边让它读起来像空出来的位置而非内容两行之间的细线是需要刻意瞄准的目标而槽是一个可以放进去的位置。槽是行自身的伪元素::before/::after所以下方各行随之整体下移而被瞄准的那一行保持在指针所指的位置不动。对应 CSS 规则.workspace-node.drop-before::before/.drop-after::after/.drop-inside::after共享一条槽规则槽高 30px、2px 虚线inside 槽额外margin-inline-start: 16px缩进一层且使用逻辑属性以便 RTL 语言自动从右缩进在 boardsList.css 中并被 tests/workspacesTree.test.cjs 以读文件断言 CSS 规则存在的方式钉住。自我嵌套与后代嵌套整个子树会被切断一个工作区不能被拖进它自己也不能被拖进它自己的后代——否则该子树会从根上被切掉变成不可达的环其下所有工作区一并丢失。拒绝的方式是不调用preventDefault()——这正是 HTML5 拖拽说不的方式——于是光标在拖拽悬空时就显示拒绝而不是等 drop 落下后悄悄什么都不做。前端dragover .workspace-node处理器在标记落点前先做targetEl draggingEl || draggingEl.contains(targetEl)判断并提前 return见 boardsList.js后端纯函数isSelfOrDescendant(node, id)见 models/lib/workspacesTree.js递归检查id 是否是 node 自身或其下任意节点。测试用例覆盖了拖进自己、拖进自己的直接子、拖进自己的孙代、或拖到自己子项旁边全部返回 null 的场景。拖出来是另一种普通 drop回到上层是一次与其他任何一次都相同的 drop把子工作区拖到某根级行的边缘它就重新成为根级工作区。嵌套必须可撤销否则一个放深了一层的子工作区就永远卡在那里了。moveWorkspace()的测试明确验证了把子节点拖到根级行下边缘后它脱离父级、父级保留一个空 children 列表。拖拽从哪里开始手柄开关与两个容易漏掉的细节手柄开关决定拖拽起点「显示桌面拖拽手柄Show desktop drag handles」开关位于第一个顶部标题栏与泳道、列表、卡片、看板磁贴共用同一个设置都通过isTouchScreenOrShowDesktopDragHandles助手判断手柄开启✥ 手柄且只有手柄可以发起拖拽手柄关闭工作区的图标和名称——即整个a.js-select-space锚点。点击它仍然会打开工作区点击和拖拽是同一个元素上的两种手势。整行永远不是拖拽起点行上还有 ⋯ 菜单和数量从那些元素上发起的拖拽是别的东西的拖拽。因此draggable只挂在手柄或锚点上而dragstart处理器留在行上——因为事件从任一子元素冒泡上来。让手柄关闭拖拽真正可用两个细节有两个细节容易漏掉漏掉任一个都会让重新排序不好使.nodragscroll页面级的 dragscroll 会接管一切没有主动退出的 mousedown拖拽从未开始的表现就是重新排序没反应user-select: none拖拽源期间名称是文本在可选中文本上按住移动会启动选区——浏览器从此接管手势元素拖拽根本不会开始。手柄从来不需要这个因为 span 里的字形不是会被选中的文本。一个 drop 端必须修掉的 bug把锚点文本当成了看板当拖拽从锚点手柄关闭时开始时浏览器会把该锚点的文本自动塞进text/plain。旧的 drop 处理器先读text/plain于是把工作区误判为看板赋值失败行又弹回原位——而手柄开启时从 span 起步、span 不携带文本所以同样的 drop 在那边一直正常这正是为什么只在一种模式下坏的线索。现在的dragstart在设置自己的数据类型之前先调用clearData()见 boardsList.js而 drop 处理器先读application/x-workspace-id、再考虑text/plain见 boardsList.js。tests/workspacesTree.test.cjs 用源码文本断言固定了这个修复clearData()必须出现在setData(...)之前工作区 id 的getData必须先于getData(text/plain)。无限深度模板自己画自己树之所以没有深度上限是因为模板递归调用自身workspaceTree模板在if workspaceShowsChildren下以子节点为参数再次包含自己——workspaceTree(nodeschildren selectedWorkspaceId../selectedWorkspaceId)见 boardsList.jade。没有任何地方存在层级计数需要被调高。测试tests/workspacesTree.test.cjs用五层嵌套验证了没有深度限制工作区可以嵌套到它被拖到的任何深度且每一层都仍从根可达。每一层通过padding-inline-start缩进一个箭头宽度——用逻辑属性因此从右到左RTL的语言会自动从右侧缩进子工作区的箭头落在其父工作区名称的正下方。拖拽落盘先渲染、后保存与两个纯函数守卫把以上规则串起来的执行路径是 boardsList.js 中的moveWorkspaceInTree(draggedId, targetId, position)先调isNoOpMove()——把工作区放回原位的 drop 会写出同一棵树并白白重渲染直接跳过调纯函数moveWorkspace(tree, draggedId, targetId, position)得到新树该函数对输入做 JSON 深拷贝绝不修改传入的树调用方可安全比对/丢弃/上送见 models/lib/workspacesTree.js先在屏幕上生效this.workspacesTreeVar.set(next)随后Meteor.call(setWorkspacesTree, next)落盘——面板不能干等服务器响应而页面上已有的 autorun 会在服务器树返回后接管渲染。服务端setWorkspacesTree(newTree)见 server/models/users.js只做check(newTree, Array)与一次Users.updateAsync写入profile.boardWorkspacesTree——树的一切结构与合法性守卫都在纯函数层完成。测试 tests/workspacesTree.test.cjs 断言了isNoOpMove、moveWorkspace、setWorkspacesTree三者在此处理器中的出现顺序并验证同一棵树在被 move 之后保持原样不可变性。相关文件一览文件路径类型职责models/lib/workspacesTree.js纯 JS 模块拖拽对树做什么dropPosition()与moveWorkspace()以及让子树保持挂接在根上的守卫。无 Meteor、无 DOMclient/components/boards/boardsList.jadejade 模板workspaceTree——行、折叠箭头以及赋予树深度的自我递归client/components/boards/boardsList.jsBlaze 模板逻辑箭头的 helpers 与事件、拖拽/放下接线、落点标记client/components/boards/boardsList.css样式表箭头、每层缩进、drop 槽client/lib/utils.js客户端模块getWorkspaceCollapseState()/setWorkspaceCollapseState()Session 值、profile 字段与未登录 cookiemodels/users.js模型profile.boardWorkspacesTree、profile.collapsedWorkspaces以及未登录阅读者的 cookie 辅助server/models/users.js服务端模型setWorkspacesTree与setWorkspaceCollapsedtests/workspacesTree.test.cjsNode 测试三种移动及其拒绝规则以及页面确实接入了这些规则而非另起一套设计要点速查数据模型profile.boardWorkspacesTree是{ id, name, icon, iconColor, children }的纯数据数组按用户隔离深度不限。URL/allboards/workspaces/slug/slug/...逐层取名字的 slug与看板同一套getSlug/limax 逻辑emoji 名兜底用 id同名 slug 先到先得。折叠默认展开只存折叠键Session →profile.collapsedWorkspaces→ cookie 三层展开用$unset删键id 过^[A-Za-z0-9_-]{1,64}$校验因写入点号字段路径。拖拽上/下 1/4 前后兄弟中间一半 最后一个子项拒绝自我/后代嵌套靠不 preventDefault占位是行伪元素画出的一行高空槽。起点手柄开✥ 手柄手柄关图标名称锚点需.nodragscroll与user-select: nonedragstart 先clearData()drop 先判工作区 id。无限深度模板自递归 padding-inline-startRTL 自动镜像逐层缩进。落盘纯函数算新树不可变→ 先渲染 → 后setWorkspacesTree持久化。相关文档All Boards——工作区树所属的页面The All Boards URLs——工作区如何被寻址Left menu——绘制这棵树的左侧面板及其自身的折叠【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考