
WeKan All 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/wekanAll Boards/board及其 Starred / Templates / Remaining 等分区是 WeKan 看板应用中用户登录后的核心入口页面它由一条左侧菜单、一条头部控件栏和面板中呈现的看板视图组成。本文以 docs/Features/Page/All-Boards.md 设计文档为主线结合models/lib/allBoardsUrls.js、models/lib/allBoardsSidebar.js、client/components/boards/boardsList.js与tests/allBoardsPage.test.cjs等源码与测试完整讲解该页面的控件布局、右侧边栏、视图切换、表格视图以及工作区重排的底层实现。读完本文你将掌握 WeKan All Boards 页面的完整设计决策链并能据此理解其模板、样式与路由之间的协作方式。页面总体结构左菜单 头部控件栏 面板All Boards 页面由三部分构成一条左侧菜单列出 Starred、Templates、Remaining 等分区、工作区树以及 Archive一条头部控件栏位于页面第一行、始终可见的顶部头栏显示All Boards / Starred承载本页的全部控件看板视图区即面板中呈现的看板本身包括「卡片墙」视图board icons和「表格」视图两种呈现方式。其中表格视图是一个完整的表格式页面表格的布局、控件与分页规则统一定义在 Table 页面设计 中本页只描述属于自己的那部分差异。也就是说All Boards 的表格复用了一套全应用统一的表页实现模板为client/components/settings/tablePage.jade纯函数位于models/lib/tablePage.js而非另起炉灶。从模板层面看页面主体定义在 boardsList.jade 的boardList模板中.wrapper.all-boards-wrapper内先渲染allBoardsSidebar然后是boards-layout左侧菜单 右侧看板网格右侧网格中先渲染paneTitle(allBoardsPaneTitle)标题再根据isAllBoardsView table分支决定渲染共享的tablePage(tablePageData)还是看板图标列表。控件全部集中在头部栏本页只有一行控件且位于第一行顶部头栏——就是那个始终在屏幕上、写着All Boards / Starred的头栏。控件放在通知铃铛的左侧与看板自身控件的位置一致样式也完全相同使用.board-header-btn类、相同的 Font Awesome 图标以及控件开启时相同的强调emphasis状态。控件图标作用Sortfa-sort打开看板排序弹窗boardsSortPopup。当启用了非自定义顺序的排序时显示强调态。Searchfa-search打开右侧边栏并直接进入搜索视图见 Search。Multi-Selectionfa-check-square-o开启看板多选并打开承载选择操作的侧边栏见 Multi-Selection。Lists / Tablefa-trello/fa-table视图菜单见下文「视图菜单」一节。每个控件在栏宽允许时会在图标旁显示名称在1100px 以下时与其它控件一起隐藏文字标签只保留 tooltip——这条规则由 Header 设计 中的一条header.css规则统一处理见 boardsList.jade 中allBoardsHeaderButtons模板的注释。这组控件在 boardsList.jade 的allBoardsHeaderButtons模板中实现事件处理注册在 boardsList.js 的Template.allBoardsHeaderButtons.events中Search 与 Multi-Selection 按钮调用openAllBoardsSidebar(SIDEBAR_SEARCH)/openAllBoardsSidebar(SIDEBAR_MULTISELECTION)直接打开对应视图Multi-Selection 的 ✕ 关闭按钮.js-multiselection-reset调用BoardMultiSelection.disable()并closeAllBoardsSidebar()同时stopPropagation防止点击穿透到旁边的开启按钮。值得注意的是控件并非一直就长这样——它们曾先后出现在三个位置页面正文的一行、第二条顶部头栏、右侧边栏的 home 视图。最后一个方案的代价是要打开面板才能触达你要找的功能。如今它们是一键直达且没有任何东西遮挡页面。tests/allBoardsPage.test.cjs中的测试明确断言js-open-boards-sort、js-all-boards-sidebar-search、js-all-boards-sidebar-multiselection必须出现在侧边栏中除视图菜单外且模板中不再存在boardListHeaderBar、boards-path-header等旧结构。Archive 不是控件而是左侧菜单的一行上述三个控件作用于你眼前的看板而Archive归档是一个你「前往」的场所因此它被放在左侧菜单里——四个菜单行的最后一行并像上面三行看板列表一样带有一个计数。它只叫Archive一个词与旁边的 Starred、Templates、Remaining 保持一致。其标签使用已有的 i18n keyarchives而分区的标题也复用同一个 key这样头栏的面包屑与高亮的菜单行不可能用两种方式命名同一个地方。它作为本页的一个分区打开绘制在菜单旁边——而不是一个替换掉菜单的全宽独立页面。其地址为/allboards/archive与其它四个分区同构模板是归档自己的因此归档的搜索与分页随模板一并生效。独立的/archive页面仍然保留用于直接链接侧边栏中的那一行也仍然指向它。归档计数的实现很讲究它通过服务端方法getArchivedBoardsCount获取该方法正是归档分页器在用的那个而不是从当前页面持有的数据里数出来。原因在 boardsList.js 的onCreated中有详细注释All Boards 页面的查询是archived: false根本不订阅已归档看板归档自己的 publication 按 30 条分页因此如果对 minimongo 计数冷加载时会得到 0之后又会得到某个随意值。代码中用this.refreshArchivedBoardsCount () Meteor.call(getArchivedBoardsCount, , ...)实现结果存入archivedBoardsCount这个ReactiveVar。看板可以被拖到 Archive 行上完成归档——可以从四个列表中的任意一个也可以从工作区拖过来。左侧菜单已经支持向 Remaining 行投放所以这个手势是用户已经掌握的替代方案则是要经过 Multi-Selection 的三次点击。拖放前会先询问确认因为误拖很容易发生——比如一个本来只想重排的看板不小心落低了一行。面板的标题命名当前所在的分区右侧面板打开时以一个标题来命名你正在查看的分区——Starred、Remaining、Home、Templates、Archive或某个工作区自己的名字。页面本质上是「一个名字下的多组看板列表」没有这个标题时面板直接以看板磁贴开始没人知道这是哪一组。这个标题复用管理后台自己的paneTitle模板和同一个.admin-pane-title类因此两个页面共享同一字号、同一颜色的标题而不是两份各自漂移的样式副本。本页只设置标题下方的间距管理后台的间距规则作用域限定在它自己的.main-body内不会作用到本页。测试allBoardsPage.test.cjs断言paneTitle(allBoardsPaneTitle)只出现一次、位于视图分支之前——因为看板图标与表格是同一个分区的两种呈现方式而不是两个分区标题只需绘制一次。标题的文字取自分区自己的标题 key——与第一行头栏命名页面的 key、以及高亮菜单行携带的 key 是同一个三者永远说同一句话。工作区是例外工作区的名字是用户自己输入的因此作为纯文本 label 传入绝不经过翻译器——一个名叫 starred 的工作区并不是 Starred 分区。在models/lib/allBoardsUrls.js中ALL_BOARDS_SECTION_TITLE_KEYS映射了每个分区的标题 keyallboards.starred、allboards.templates、allboards.remaining、allboards.workspaces、archives、homesectionTitleKey()负责解析。测试中还验证了每个 key 都存在于英文翻译文件imports/i18n/data/en.i18n.json中。页面默认打开哪个分区Starred 一直是默认落点但对一个什么都没星标的账号来说那就是一个空页面压着一个满页面——看起来像 WeKan 弄丢了你的看板而不是「这个分区你还没填充」。因此地址/不指明分区在有星标看板时打开 Starred没有任何星标时打开 Remaining菜单把那个落点排在最上面于是高亮行就是第一行。该规则由 allBoardsUrls.js 中的defaultSection()与menuSectionOrder()实现——一个布尔值进一个分区名或按序排列的五行出——页面与它的守卫读取的是同一份逻辑// 纯函数布尔进、分区名出。「是否星标过任何看板」由调用方去查——本模块 // 不依赖 Meteor 与任何集合。 function defaultSection(hasStarredBoards) { return hasStarredBoards ? SECTION_STARRED : SECTION_REMAINING; } // 左侧菜单的行序。TOP 行就是页面打开时所在的行。 function menuSectionOrder(hasStarredBoards) { const boardLists hasStarredBoards ? [SECTION_STARRED, SECTION_REMAINING] : [SECTION_REMAINING, SECTION_STARRED]; return [...boardLists, SECTION_HOME, SECTION_TEMPLATES, SECTION_ARCHIVE]; }只有前两行会移动Home、Templates 与 Archive 始终待在它们下面。路由不回答这个问题路由在用户文档未必加载完成之前就执行了所以它会对/写入null由页面自己决定。页面在一个autorun中决定原因相同冷加载时用户文档在模板创建之后才到达一次性读取会对所有用户回答「什么都没星标」。它还读取用户文档自己的profile.starredBoards字段而不是user.starredBoards()——后者的 Boards 查询依赖订阅状态会在加载过程中从「无」跳到「有」导致页面先画 Remaining、又在用户眼前跳到 Starred。在 boardsList.js 的onCreated中这个 autorun 这样写this.autorun(() { const named Session.get(boardListMenu); this.selectedMenu.set(named || defaultSection(hasStarredBoards())); });一旦地址确实指名了某个分区一切以地址为准点选菜单行会导航路由把名字写进 Session上述回退逻辑随即失效——它不会与用户已经做出的选择打架。菜单行是同一段标记、每个分区渲染一次因为依赖用户的行序不可能用五份相同的八行代码来实现那样重排就得搬动标记。行内的图标与名称存放在循环旁边的一张映射表里。左侧菜单的样式与分隔左侧菜单在同一列里承载三种东西——三个看板列表、工作区树和归档——因此 Workspaces 分区上下各有一条规则线。没有它们树就会像同一列表的延续一样挤进邻居。菜单本身按管理后台的风格样式化见 Left menu并且像它一样触达窗口的左边缘与底边缘——它是窗口的一个固定侧边而不是漂浮在页面里的内容。管理后台之所以免费获得这一点.setting-content位于.wrapper之外以绝对定位铺满全宽全高。而 All Boards 在.wrapper内的正常文档流中.wrapper是width: calc(100% - 28px); margin: 0 auto因此它选择退出那个缩进高度取100vh减去--wekan-header-height——即头部实测自身的高度头栏会换行不是固定高度。这是一个最小值看板多到放不下时页面仍会长高越过它。菜单行也遵循同样规则不只是颜色相同的2px 4px边距、相同的块级内边距与阅读方向缩进没有圆角——选中行是横贯菜单的一块而不是漂浮在其中的一颗药丸。面板自身不加侧边内边距——行自带边距面板再加会让每一行被二次缩进。Workspaces 使用管理后台的分组标题处理更小、大写、更安静因为它命名的是一个分组不是其中某一项。整体上它是一个自带背景、边框和圆角的面板选中行以每个用户主题的强调色填充并配白色文字悬停为带阴影的白色。WeKan 只有一种左侧菜单它就应该看起来像一种左侧菜单。菜单过长时在面板内部滚动而不是溢出自己圆角之外——这是 Problems 菜单当年需要的同一个修复。Workspaces 上下规则线是一条 2px 的#888深灰线——并且是本样式表已经在用的那种灰。第一版是#e0e0e0发丝线与菜单自身右边缘同灰淡到分不开任何东西浅灰需要面积才能被看见而面积恰恰会把分隔线变成另一行。深灰能作为一条线被看清所以它保持为线。用 2px 而非 1px是因为在 1px 时中调灰的规则可能落在一个半像素边界上在分数倍缩放的屏幕上被画成两条更浅的行——那正是这条线本想修复的淡弱问题。本页没有汉堡菜单本页没有汉堡菜单也没有它前面的分隔线。汉堡的唯一目的地是那个 home 视图而它列出的正是这四个同样的事物为了够到已经一键可达的东西再开一个菜单是多余的一步。侧边栏仍然会被打开——由 Search 和 Multi-Selection 直接打开到各自视图。NO_HAMBURGER_ROUTES在 pageSidebar.js 中定义正是它说了算const NO_HAMBURGER_ROUTES [ home, allboards, allboards-templates, allboards-remaining, public, ];而看板路由被刻意排除在外看板侧边栏承载的内容——成员、标签、活动、设置——不在头部栏里也没有别的打开方式。hasHamburger(routeName)与hasOwnSidebar(routeName)两个纯函数分别回答「是否有汉堡」「是否已有自己的侧边栏」。Starred 在这里不是一个控件Starred 是一个分区左侧菜单已经把它列在 Templates 与 Remaining 旁边、给它计数、并在显示它时高亮它。距离第一步只有一个点击的距离的第二个到达方式是一个只用于和菜单保持同步的按钮。tests/allBoardsPage.test.cjs断言侧边栏中既没有data-typestarred也没有js-select-menu而左侧菜单仍然通过each menuSections绘制分区行。选中后的动作放在侧边栏选中multi-selection后的动作在侧边栏里不在头部栏。它们曾经是挤在控件旁的四枚纯图标按钮侧边栏给每个动作配上名称而看板自身也是这么放置这些动作的。见 Multi-Selection。tests/allBoardsPage.test.cjs断言js-archive-selected-boards、js-duplicate-selected-boards、js-star-selected、js-home-selected必须在侧边栏中侧边栏视图模板在 allBoardsSidebar.jade 的allBoardsMultiSelectionSidebar中实现。每个按钮都有 tooltip 自报家门控件都是图标tooltip 就是名字唯一能待的地方——没有 tooltip 的按钮就是一张没标注的图。头部栏中每个.board-header-btn都带有title且每个 title 都来自翻译 key 而非字面英文。比如关闭 Multi-Selection 的 ✕ 曾经写着 Clear filter那是另一个控件做的事现在写multi-selection-off——因为它只有一个。在allBoardsHeaderButtons模板中可以看到Sort 的title{{_ sort-boards}}、Search 的title{{_ search-boards}}、Multi-Selection 的title{{#if BoardMultiSelection.isActive}}{{_ multi-selection-on}}{{else}}{{_ multi-selection}}{{/if}}全部走翻译。右侧边栏All Boards 有自己的右侧边栏但它不是看板侧边栏看板侧边栏围绕一块看板构建——成员、标签、活动、设置——而本页没有看板。它借用的是外壳因此两者看起来、用起来一致同样的.board-sidebar.sidebar类、同样的带 ✕ 的.sidebar-actions行、同样的带标题和返回箭头、视图之上的.sidebar-content。视图由什么打开是什么home其它视图的返回箭头页面自己的菜单Search、Multi-Selection 与 Boards in Archive那是个页面所以该行离开侧边栏而不是在其中打开。默认视图正如看板上的homeSidebar。无标题、无返回箭头——后面没有东西。现在没有任何东西打开侧边栏到这一层了因为那四个已在头部栏它是你返回时落到的地方。searchSearch 按钮SearchmultiselectionMulti-Selection 按钮Multi-Selection哪些视图存在、每个视图绘制哪个模板由纯模块 allBoardsSidebar.js 定义——它是纯的因此守卫可以检查每个视图都能解析到一个真实存在的模板。模板名是一张映射表而不是从视图名推导出来的推导曾把名为allBoardsMultiSelectionSidebar的模板拼成allBoardsMultiselectionSidebar一个字母之差就导致什么都不渲染const SIDEBAR_VIEW_TEMPLATES { [SIDEBAR_HOME]: allBoardsHomeSidebar, [SIDEBAR_SEARCH]: allBoardsSearchSidebar, [SIDEBAR_MULTISELECTION]: allBoardsMultiSelectionSidebar, };打开/关闭与当前视图是两个独立变量因此关闭不会忘记你刚才的位置关闭后再按 Search得到的是你之前那个搜索。点击正在显示视图的按钮会再次关闭侧边栏——打开它的按钮同样能关掉它。Escape 也会关闭——但只挂按键因为EscapeActions的 click 半部会在每次点击时都触发关闭包括点击侧边栏自身内容的点击。这段逻辑在 client/lib/allBoardsSidebar.js 中EscapeActions.register( sidebarView, () closeAllBoardsSidebar(), () isAllBoardsSidebarOpen(), { enabledOnClick: false }, );这里的状态是模块作用域而非模板实例作用域打开侧边栏的头部栏与侧边栏本身是两个独立的 Blaze 实例头栏渲染到布局的 headerBar 区域任一实例上的 ReactiveVar 对另一个都不可见。侧边栏的位置在桌面端它钉在视口上top为var(--wekan-header-height)——头部实测的高度由 client/lib/utils.js 中的 ResizeObserver 持续更新头部按钮会按语言和窗口宽度换行成一、二或三行因此不是固定高度——bottom为窗口底。也就是说它是全高的从头栏之下开始与看板的侧边栏完全一致。它最初继承了看板侧边栏的position: absolute那是相对最近的已定位祖先解析的。在看板上那个祖先是看板容器——已经从头栏之下延伸到窗口底部。本页没有这样的容器所以面板曾漂浮在页面中间、压在看板图标上并且内容结束它也就结束了。而且看板会向左移动而不是被遮挡.all-boards-wrapper.sidebar-open .boards-layout把 420px 的空间让回给面板图标在菜单与面板之间剩下的空间里重新排布每一枚都保持可见——420px 正是.board-sidebar的宽度见client/components/sidebar/sidebar.css不是它自造的数字。在手机上两者都不适用看板侧边栏自己的样式表已经把这个元素它带有.board-sidebar.sidebar类钉为头栏之下的全屏宽——手机就该如此。一个全宽面板旁边没有需要让开的东西。侧边栏携带主题.sidebar .sidebar-content .sidebar-btn是一个浅灰底、白字的盒子。在看板上让它可读的是用.board-color-*祖先把灰替换成主题色——而本页没有看板没有它每个按钮都是白字浅灰底不可见。主题采用board-color-belize——看板之外的有主题事物既定默认globalSearch.js也回退到同一个。主题放在祖先上绝不放在侧边栏自身。每一条主题化侧边栏规则都是后代选择器.board-color-belize .sidebar .sidebar-content .sidebar-btn { … }所以把类放在.sidebar元素上什么都不匹配。第一个版本就把它放在那里按钮依然是白字浅灰底.all-boards-sidebar-theme是一个包含侧边栏的包装器正如看板的容器包含看板自己的侧边栏。它只包住侧边栏主题不会渗漏到身后的看板图标上它脱离文档流里面的侧边栏是fixed因此不占布局。主题是标记里的一个类所以它在任何宽度下都相同只有上面的几何是仅桌面端适用的。侧边栏外壳在 allBoardsSidebar.jade 中实现.all-boards-sidebar-theme(class{{themeClass}}) .board-sidebar.sidebar.all-boards-sidebar(class{{#if isSidebarOpen}}is-open{{/if}}) .sidebar-actions a.sidebar-xmark.js-close-all-boards-sidebar(aria-label{{_ close}}) #10005; .sidebar-content.js-all-boards-sidebar-content if sidebarViewTitle h2 a.fa.fa-arrow-left.js-all-boards-sidebar-home(aria-label{{_ go-back}}) sidebarViewTitle if isSidebarOpen Template.dynamic(templatesidebarViewTemplate)测试allBoardsPage.test.cjs专门校验主题类必须在包装器上而非侧边栏元素上且.board-color-belize必须真的定义了侧边栏按钮的样式规则。视图菜单按钮显示当前视图的名称而不是 Board View 字样——与看板头部一致后者显示 Swimlanes 或 Lists 而不是菜单名。它提供两个选项视图图标是什么Listsfa-trello看板图标墙。默认视图。Tablefa-table下方的表格页面。Lists 是默认值从未选择过的账号看到的就是它。视图菜单在 boardsList.jade 的allBoardsViewMenu模板中实现按钮同时显示下拉箭头fa-caret-down与当前视图的图标/标签。选择按浏览器记住localStorage而不是记在用户文档上它只是本页的一个视图偏好不改变任何别人能看到的东西也不值得一个 profile 字段或一次往返。这是 All Boards 刻意与看板不同的一点——看板的视图在 profile 上因为它要跨设备跟随用户。tests/allBoardsPage.test.cjs用剥离注释后的代码断言client/lib/allBoardsView.js中不含profile并且catch (e)让拒绝存储如隐私模式也可存活——选择在本次会话内仍然生效。纯逻辑部分在 models/lib/allBoardsView.jsVIEWS [lists, table]、DEFAULT_VIEW lists、存储键wekan-all-boards-viewnormalizeAllBoardsView对非法值返回null可区分「从未选择」与「选择了 Lists」resolveAllBoardsView回退到默认。客户端胶水在 client/lib/allBoardsView.jsviewVar模块作用域 ReactiveVar因为头栏与页面是两个 Blaze 实例window.localStorage.getItem/setItem(STORAGE_KEY)。同文件还导出了两个共享的 ReactiveVarallBoardsSearchVar搜索词与allBoardsMenuVar选中分区页面通过this.boardSearchVar allBoardsSearchVar; this.selectedMenu allBoardsMenuVar;把它们赋到实例上保证搜索框过滤列表。表格视图All Boards 的表格是一个可编辑的表格页面——这正是它与 Public Boards另一张看板表格只读的区别所在。列是什么Edit一支铅笔打开编辑看板标题弹窗——与 Swimlanes 视图从看板菜单打开的同一个boardChangeTitlePopup同样的标题与描述字段、同一个 Rename 按钮。Board title看板标题。是链接点击打开看板。Board description看板的描述。列定义在 boardsList.js 中const ALL_BOARDS_COLUMNS [ { labelKey: edit }, { labelKey: title }, { labelKey: description }, ];Edit 弹窗是同一个弹窗不是副本。让它成立只改了一处它的提交处理器原本读Utils.getCurrentBoard()——那是你正看着的看板——而 All Boards 上没有「当前看板」。现在它在有数据上下文时从自己的 data context 取看板否则回退到当前看板因此看板头部行为完全不变而表格行可以为该行的看板打开弹窗。测试断言行点击Popup.open(boardChangeTitle)且boardHeader.js中的提交处理器同时包含Template.currentData()与Utils.getCurrentBoard()两个分支。表格每页 10 行使用共享常量TABLE_PAGE_ROWS_PER_PAGE在models/lib/tablePage.js中定义为 10全应用统一。表格展示左侧菜单选中分区下的看板再经搜索字段过滤——与 Lists 视图绘制的同一组看板。这由boardsForView(tpl)单一函数保证Table 的tablePageData()调用const all boardsForView(tpl)Lists 视图同样return boardsForView(Template.instance())两份拷贝就等于两个「我正在看哪些看板」的答案。行模板allBoardsRow在 boardsList.jade 中定义携带看板颜色类单元格顺序与表头严格对应Edit、Board title、Board description。左侧保留的内容左侧菜单保留它的分区Starred、Templates、Remaining、工作区树以及组织 / 团队过滤器。Starred现在只在这里头部栏曾有一个 Starred 按钮在旁边到同一个分区的一键之遥的两种到达方式是一个只能和菜单保持同步、却毫无收益的控件。工作区行显示什么工作区树的完整设计——折叠、嵌套、投放槽与深度——见 Workspaces。下面只讲与本页左侧菜单相关的部分。工作区行依次是图标、名称、⋯菜单然后其中的看板计数——计数在最后、行尾与上方分区计数的位置一致。选中的工作区以主题强调色填充且填充覆盖.js-select-space图标和名称——不是整行菜单与计数位于其后的面板浅灰底上。因此无论行是否选中计数样式相同。它曾有一个浅色药丸「为了与填充行形成对比」——结果是白字浅灰底选中工作区成了唯一读不出看板计数的那一行而它恰恰是你刚问「这里面有哪些看板」的那一行。测试断言不存在.workspace-node.active .workspace-count规则强调色并没有覆盖到计数没有东西需要浅色药丸去对比并且background: #ddd的灰色药丸对选中与否一致。菜单和计数是flex: 0 0 auto名称锚点是flex: 1且带min-width: 0因此长工作区名以省略号自我截断而不是把两个邻居挤出本行。重排一个工作区工作区行遵守第一条头部栏里的Show desktop drag handles显示桌面拖拽手柄开关——与泳道、列表、卡片和看板磁贴遵守的是同一个设置经由同一个isTouchScreenOrShowDesktopDragHandleshelperclient/lib/utils.js。手柄开启——✥ 手柄画在行首它是工作区拖拽唯一的起点。手柄关闭——无手柄工作区的图标和名称——整个a.js-select-space——负责重排。为什么是图标和名称一个 16px 的字符是必须瞄准的靶子而旁边的名称正是读者本来就指着的那部分行。点击同一个锚点仍然打开工作区点击与拖拽是同一个元素上的两种手势正如手柄关闭时看板磁贴上的情形。但不是整行——行上还有 ⋯ 菜单与看板计数从它们上面开始的拖拽是拖了别的东西。所以draggable放在手柄或锚点上绝不放在行上而dragstart处理器留在行上因为事件从发起它的子元素冒泡上来。有两件小事让「无手柄拖拽」真正可用而不是只写了声明两件都很容易漏掉拖拽源锚点上的user-select: none。名称是文本按住移动划过可选中文本会开始一次选区——浏览器从此接管这个手势元素拖拽永远不会开始。手柄从不需要它因为 span 里的字符不是你会去选中的文本。.nodragscroll——手柄携带的同一个类。页面级 dragscroll 会接管没有退出的 mousedown而「永不开始的拖拽」正是「重排不工作」的样子。光标在那个锚点上保持pointer而不是变成grab打开工作区是读者对它几乎每一次都会做的动作。所有确实变化的东西都跟随draggable属性本身选择器中的[draggabletrue]而不是第二个类因此「现在它是不是拖拽源」只有一个答案。在 boardsList.jade 的workspaceTree模板中可以看到手柄开启时span.workspace-drag-handle.nodragscroll(draggabletrue)锚点a.js-select-workspace.js-select-space.nodragscroll的draggable则按开关条件设置。相关文件下表仅列本页专属的部分所有表页共享的内容见 Table.md 自己的清单两个共享头部栏控件各有清单见 Search 与 Multi-Selection。文件路径文件类型说明client/components/boards/boardsList.jade.jade模板boardList页面、boardListHeaderBar控件与allBoardsRow表格行。client/components/boards/boardsList.js.jsBlaze 模板逻辑控件的处理器、视图切换、表格列定义。client/components/boards/boardsList.css.css样式表页面自身的布局。头部栏与侧边栏复用看板头部与看板侧边栏的样式而不是重述一遍。client/components/boards/allBoardsSidebar.jade.jade模板侧边栏外壳与它的三个视图。client/components/boards/allBoardsSidebar.js.jsBlaze 模板逻辑视图的 helpers 与 handlers。models/lib/allBoardsSidebar.js.js纯模块存在哪些侧边栏视图、它们的标题与各自绘制的模板。无 Meteor 依赖因此可单测。client/lib/allBoardsSidebar.js.js模块侧边栏是否打开、在哪个视图上以及关闭它的 Escape 动作。models/lib/allBoardsView.js.js纯模块存在哪些看板视图、哪个是默认值以及未识别的存储值如何被处理。client/lib/allBoardsView.js.js模块Meteor 与浏览器胶水模板间共享的 ReactiveVar它们是独立的 Blaze 实例与存放视图选择的localStorage。client/components/boards/boardHeader.jade.jade模板boardChangeTitlePopup与 Swimlanes 视图共享。client/features/boards.js.js导入清单上面每个.jade都必须在这里被导入否则其模板在运行时不存在。tests/allBoardsPage.test.cjs.cjsNode 测试控件只在头部栏且别处没有、侧边栏的外壳与视图、视图菜单的两项与默认值、表格的列、以及 Edit 弹窗是共享的那个。tests/templateRegistration.test.cjs.cjsNode 测试每个.jade都被导入且它 include 的每个template都存在。相关设计文档Boards in Archive——侧边栏的 Archive 行打开的页面The All Boards URLs——每个左侧菜单项一个 URL工作区也包含在内/allboards/workspaces/engineering/backend形式的 slug 路径由 allBoardsUrls.js 的workspaceSlugPath/workspaceIdForSlugPath解析Search——共享的 Search 控件与两个搜索视图Multi-Selection——共享的 Multi-Selection 控件与两个视图The Table page design——所有表页共享的一切Public Boards——另一张看板表格只读Workspaces——左侧菜单中的树折叠、嵌套与拖拽Left menuHome——登录后打开的 Home 看板作为本页的一个分区【免费下载链接】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),仅供参考