WeKan 搜索功能设计全解析:从头部按钮到双视图侧边栏的完整实现 📅 发布时间:2026/9/13 11:39:19 👁 浏览次数: WeKan 搜索功能设计全解析从头部按钮到双视图侧边栏的完整实现【免费下载链接】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 看板应用基于 Meteor 构建的搜索功能展开系统讲解其「头部栏按钮 右侧边栏搜索视图」的统一设计同一个模板如何在看板页与「全部看板」页两处复用、为什么用按钮而非输入框、两个搜索视图各自的状态管理与交互细节以及searchCards/searchLists的底层查询实现。读完本文你将完整掌握 WeKan 搜索从 UI 到数据查询的整条调用链并能直接对照源码定位每一处实现。搜索功能整体架构一个按钮、两个页面、同一套设计搜索功能是 WeKan 中一个头部栏按钮header-bar button点击后打开右侧边栏并切换到搜索视图。这一行为在看板页Board包括泳道、列表等视图和「全部看板」页All Boards上完全一致并且两个页面使用的是同一个按钮——一个模板被包含include了两次。这一点在 docs/Features/Page/Search.md 中开宗明义同一个按钮在两处出现意味着同一份标记markup承担两种语义而这正是 Blaze 事件委派event delegation机制的用武之地——按钮自身不携带任何点击处理器carries no handler of its own由外部模板的事件表event map统一捕获。按钮控件模板、类名、图标与标签根据文档表格搜索按钮的四个关键属性如下属性值模板headerSearchButton位于 boardHeader.jade类名a.board-header-btn.js-open-search-view图标fa-search标签{{_ search}}i18n 键随界面语言翻译在 boardHeader.jade 中可以找到该按钮的真实标记a.board-header-btn.js-open-search-view(title{{_ search}}) i.fa.fa-search span.board-header-btn-label {{_ search}}它位于if isFoldOpen board-controls区块内与筛选filter、依赖关系dependencies、多选multi-selection按钮并排构成看板头部的一列工具按钮。类名中js-前缀表明它只承担 JavaScript 钩子职责不带任何业务样式。为什么是按钮而不是输入框「全部看板」页曾长期把搜索输入框放在头部栏里理由是「过滤器应该待在它过滤的那条栏里」。这个推理本身没问题但实际结果却制造了不一致两个页面看板页与全部看板页在同一位置放了名称相同但控件不同的东西只有其中一个的行为与 WeKan 其余部分保持一致。于是「按钮」成为统一答案搜索字段并没有消失它只是变成了侧边栏搜索视图的内容本身the field did not disappear — it is what the sidebars search viewis。改变的只是它的位置从头部栏移入侧边栏和到达它的方式从直接输入变为点击按钮。这一设计取舍的完整讨论见 docs/Features/Page/Search.md 的 Why a button and not a field 一节。同一按钮的两种语义Blaze 事件委派按钮没有自己的处理器但两个页面各自渲染模板的事件表会捕获来自按钮的点击事件页面点击后调用搜索范围看板泳道、列表等Sidebar.setView(search)该看板的卡片与列表全部看板openAllBoardsSidebar(SIDEBAR_SEARCH)你的看板看板侧的点击处理在 boardHeader.js 中Template.boardHeaderBar.events捕获点击并调用toggleSidebarViewclick .js-open-search-view() { toggleSidebarView(search, false); },toggleSidebarView(view, mustStayOpen)定义在同文件 L168 附近。代码注释特别说明了一个细节搜索会直接关闭它之前打开的面板且没有例外Search shuts what it opened too, with no exception——因为搜索结果就在面板内部关闭面板并不会像关闭活动筛选器那样对看板隐藏任何内容参见 models/lib/sidebarViewButton.js 的设计注释。全部看板侧的点击处理在 boardsList.js 中Template.boardListHeaderBar.events捕获点击并直接打开侧边栏的搜索视图click .js-all-boards-sidebar-search(evt) { evt.preventDefault(); openAllBoardsSidebar(SIDEBAR_SEARCH); },SIDEBAR_SEARCH、SIDEBAR_HOME、SIDEBAR_MULTISELECTION等视图常量定义在 models/lib/allBoardsSidebar.js侧边栏的打开/关闭状态由 client/lib/allBoardsSidebar.js 管理。All Boards 搜索视图页面级搜索词「全部看板」的搜索视图是allBoardsSearchSidebar模板位于 allBoardsSidebar.jadetemplate(nameallBoardsSearchSidebar) input.js-board-search-input( typetext aria-label{{_ search-boards}} value{{boardSearch}} autofocus autocompleteoff dirauto) if boardSearch a.sidebar-btn.js-board-search-clear i.fa.fa-times span {{_ filter-clear}}它由两部分组成一个自动聚焦autofocus的文本输入框一个「清除」行当输入框非空时才出现。搜索词属于页面而非模板视图本身不持有搜索词它写入的是allBoardsSearchVar——一个定义在 client/lib/allBoardsView.js 的模块级ReactiveVarexport const allBoardsSearchVar new ReactiveVar(); export function allBoardsSearch() { return allBoardsSearchVar.get(); } export function setAllBoardsSearch(term) { allBoardsSearchVar.set(typeof term string ? term : ); }这是全篇设计的关键搜索词是页面自己的状态the pagesownsearch term, not a copy而不是模板实例的私有状态。原因注释写得很清楚侧边栏控件渲染在布局的headerBar区域而看板列表渲染在页面的content区域它们是两个独立的 Blaze 模板实例任何一方的实例级ReactiveVar对另一方都不可见模块作用域module scope的状态才是两者共享的桥梁。正因为如此当你在输入框中打字时侧边栏背后的看板列表会实时收窄narrows as you type——这正是当年头部栏输入框的效果。且「列表视图」Lists和「表格视图」Table都读取同一个搜索词因此两个视图都会同步过滤。在 allBoardsSidebar.js 中可以印证这一数据流Template.allBoardsSearchSidebar.helpers({ boardSearch() { return allBoardsSearchVar.get(); }, }); Template.allBoardsSearchSidebar.events({ input .js-board-search-input(evt) { allBoardsSearchVar.set(evt.currentTarget.value); }, keydown .js-board-search-input(evt) { if (evt.key Escape) { if (allBoardsSearchVar.get()) { evt.preventDefault(); evt.stopPropagation(); allBoardsSearchVar.set(); evt.currentTarget.value ; } } }, click .js-board-search-clear(evt, tpl) { evt.preventDefault(); allBoardsSearchVar.set(); const input tpl.find(.js-board-search-input); if (input) { input.value ; input.focus(); } }, });input事件将每次键入实时写入allBoardsSearchVarEscape键逻辑当输入框有内容时先清空preventDefaultstopPropagation阻断冒泡当输入框已为空时才落到侧边栏自身的 Escape 关闭逻辑——这是文档强调的交互准则搜索框上的 Escape 优先表示「撤销搜索」其次才是「关闭面板」「清除」按钮点击后清空搜索词、清空输入框内容并重新聚焦输入框方便连续输入新词。样式由全局规则接管有趣的是这个搜索视图完全不由本页面自己定义样式。文档指出.sidebar .sidebar-content input[typetext]位于看板侧边栏的样式表中给所有侧边栏输入框提供了全宽外观而该样式表在所有页面都会加载。文档还记录了一个真实的踩坑教训一个在模板之间移动的控件要么随身带走它的样式表要么就会悄悄失去样式。.boards-path-header .board-search曾经连续两个 commit 匹配不到任何元素而输入框一直以浏览器默认尺寸渲染。这解释了为什么侧边栏输入框能保持统一外观——样式来自共享的侧边栏样式表而非搜索视图自身。搜索面板的宿主与主题搜索视图所在的面板是 All Boards 侧边栏本身其位置与主题方案详见 docs/Features/Page/All-Boards.md桌面上固定在头部栏下方直至窗口底部看板图标被左移而非遮盖手机上占满全宽两种尺寸下都使用主题色绘制。为什么必须上主题色allBoardsSidebar.js 中的注释给出了根因// .sidebar .sidebar-content .sidebar-btn is a light grey box whose text is // WHITE; what makes that readable on a board is a .board-color-* ancestor // replacing the grey with a themed colour. This page has no board, so without // a class here every button was white on light grey - unreadable, which is // exactly what it looked like. themeClass() { return board-color-belize; }侧边栏按钮的文本是白色的在看板页由.board-color-*祖先元素提供有颜色的背景而 All Boards 页没有看板因此必须显式挂一个主题类否则按钮就是「白字配浅灰底」不可读。board-color-belize是「看板外的主题元素」的既定默认色——globalSearch.js 同样回退到它。模板侧的对应实现在 allBoardsSidebar.jade主题类必须放在.sidebar的祖先元素上.all-boards-sidebar-theme因为所有主题化侧边栏规则都是后代选择器.board-color-belize .sidebar .sidebar-content .sidebar-btn类直接挂在 sidebar 自身反而匹配不到任何规则。外层容器只包裹侧边栏本身主题色不会渗漏到背后的看板图标上。看板搜索视图searchSidebar看板自身的搜索视图是searchSidebar定义在 sidebarSearches.js文档特意标注为「unchanged」未改动是既有实现。Template.searchSidebar.onCreated(function () { this.term new ReactiveVar(); }); Template.searchSidebar.helpers({ cards() { const currentBoard Utils.getCurrentBoard(); return currentBoard.searchCards(Template.instance().term.get()); }, lists() { const currentBoard Utils.getCurrentBoard(); return currentBoard.searchLists(Template.instance().term.get()); }, }); Template.searchSidebar.events({ click .js-minicard(evt) { if (Utils.isMiniScreen()) { evt.preventDefault(); Session.set(popupCardId, Template.currentData()._id); if (!Popup.isOpen()) { Popup.open(cardDetails)(evt); } } }, submit .js-search-term-form(evt, tpl) { evt.preventDefault(); tpl.term.set(evt.target.searchTerm.value); }, });与 All Boards 视图的关键差异在于搜索词是模板实例私有状态this.term模板创建时初始化为空字符串通过表单提交submit .js-search-term-form写入而不是每次击键实时过滤。它调用currentBoard.searchCards/searchLists并渲染迷你卡片minicards与迷你列表minilists在迷你屏幕上点击迷你卡片会阻止默认行为、转而打开卡片详情弹窗popup。底层查询实现searchCards与searchLists两个搜索视图最终都汇聚到 models/boards.js 中 Board 模型的方法。searchListsL2078-L2103按boardId查询列表对标题与描述做大小写不敏感的正则匹配new RegExp(term, i)并区分模板看板type: template-list、未归档与普通看板排除模板列表。searchCardsL2105-L2145则复杂得多它的查询构造被抽取为纯函数模块models/lib/cardSearch.js以便脱离 Meteor 环境做单元测试。核心匹配规则buildCardSearchOr构建$or子句function buildCardSearchOr(term) { const regex new RegExp(term, i); const or [ { title: regex }, { description: regex }, { customFields: { $elemMatch: { value: regex } } }, ]; const numeric parseNumericSearchTerm(term); if (numeric ! null) { // #5680: also match numeric custom fields (number / currency) by value. or.push({ customFields: { $elemMatch: { value: numeric } } }); } return or; }即搜索覆盖卡片标题、描述、自定义字段值以及自定义字段的数值精确匹配。数值自定义字段搜索#5680migrations/009_fix_v795_due_dates.js 之外cardSearch.js 头部注释记录了一个经典 bug 的修复number / currency 类型的自定义字段在输入时以 JS Number 保存parseInt(...)/Number(...)但旧搜索只用正则匹配{ value: regex }——而 MongoDB / Minimongo 的正则只能匹配字符串导致所有数值字段被静默跳过。例如搜索卡片的交易编号2025001或金额123永远找不到。修复方案是parseNumericSearchTermcardSearch.js#L21-L32当搜索词能解析为纯十进制数时要求严格的正则^[-]?(\d(\.\d)?|\.\d)$拒绝Number()0、Number(0x1f)31这类强制转换额外追加一条数值相等子句。因为卡片搜索在客户端跑在 Minimongo 上同样无法对数值字段做正则所以相等匹配是跨环境MongoDB 与 Minimongo都正确的方案。评论内容搜索#3841searchCards还支持只在评论里命中的卡片评论存放在独立的 CardComments 集合Minimongo 无法用单条查询跨集合匹配因此实现是——先拉取当前看板的所有评论只取cardId与text字段用与其余搜索相同的大小写不敏感正则筛出匹配的评论得到去重后的cardId列表再通过query.$or.push({ _id: { $in: commentCardIds } })并入查询。复用同一个匹配规则matchingCommentCardIds内部就是buildCardSearchOr用过的正则避免出现「两套匹配标准」的隐患。模板看板的特殊处理searchCards与searchLists都判断this.isTemplatesBoard()在模板看板中只搜type: template-card/template-list且未归档的记录普通看板则排除模板类型确保搜索不会把模板内容混入业务看板结果。测试保障设计文档明确列出了两条测试作为行为契约tests/allBoardsPage.test.cjs断言头部栏有按钮而没有输入框且侧边栏搜索视图写入的是页面级搜索词allBoardsSearchVar——直接锁定本文所述的核心设计决策tests/templateRegistration.test.cjs断言共享模板被正确导入且两个头部栏boardHeaderBar 与 boardListHeaderBar都包含了它——锁定「一个模板、两处复用」。相关文件速查文件路径类型职责client/components/boards/allBoardsSidebar.jade.jade模板allBoardsSearchSidebarAll Boards 搜索视图输入框 清除行client/components/boards/allBoardsSidebar.js.jsBlaze 逻辑搜索视图的 handlers写入页面级搜索词client/lib/allBoardsView.js.js模块allBoardsSearchVar页面据以过滤的搜索词client/components/sidebar/sidebarSearches.js.jsBlaze 逻辑看板搜索视图searchSidebarclient/components/boards/boardHeader.js.jsBlaze 逻辑看板侧的点击处理toggleSidebarViewclient/components/boards/boardsList.js.jsBlaze 逻辑All Boards 侧的点击处理openAllBoardsSidebarmodels/boards.js.js模型searchCards/searchLists的查询实现models/lib/cardSearch.js.js纯函数搜索查询构造正则、数值、评论匹配tests/allBoardsPage.test.cjs.cjsNode 测试按钮而非字段、侧边栏写入页面级搜索词tests/templateRegistration.test.cjs.cjsNode 测试共享模板被导入且两个头部栏均包含它相关设计文档Multi-Selection——另一个「共享控件」与搜索按钮一样多选按钮也是同一个标记在两处复用是理解 WeKan 共享控件模式的姊妹篇All Boards——搜索面板所宿主的 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/wekan创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考