ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WeKan 归档看板页(/archive):从浮层弹窗到可寻址页面的设计演进与实现全解

WeKan 归档看板页(/archive):从浮层弹窗到可寻址页面的设计演进与实现全解 WeKan 归档看板页/archive从浮层弹窗到可寻址页面的设计演进与实现全解【免费下载链接】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 仓库中的设计文档 docs/Features/Page/Archive.md完整梳理归档看板这一功能从弹窗modal演进为独立路由页面的设计动机、四个入口点的统一跳转、/archive路由与页面模板的实现以及搜索、服务端分页、恢复与永久删除四条核心能力在客户端、发布端publication和方法method三层的代码级实现。读完本文你能理解一个列表型页面在 Meteor Blaze 架构下如何做到可链接、可分页、权限可控且具备完整回归测试。为什么归档列表必须是一个页面而不是弹窗归档页设计的出发点是对一个交互缺陷的回应。在 WeKan 中列出所有已归档看板的唯一位置曾经是弹窗三个不同菜单都通过Modal.open(archivedBoards)打开同一个浮层。这个浮层自身带搜索框和分页器但它悬浮在你当时正在看的任何东西之上存在三个结构性问题没有地址无法把链接发给同事、无法加入书签、无法在第二个标签页打开读完之后无处可回Escape 即关闭正在阅读时按一次 Esc整个上下文就没了任务与瞥视错配恢复我上个月归档的看板是一个需要停留的任务而不是一个瞬间的瞥视。因此/archive被设计成一个页面并且与所有其他页面拥有同一条第二顶部标题栏——这是设计文档给出的第一句定性。后续所有实现路由、模板、测试都围绕这一定性展开可用node tests/archivePage.test.cjs直接验证。入口在哪四个入口点与 allBoardsPath()归档看板列表有四个入口全部使用同一个 CSS 类js-open-archived-board这正是设计文档中的入口表文件路径已转换为仓库根相对路径入口控件看板菜单boardHeader.js 中的js-open-archived-board成员菜单userHeader.js 中的同类看板侧边栏sidebar.js 中的同类All Boards 侧边栏其 home 视图中allBoardsSidebar.js的同类四个入口现在都指向/allboards/archive——All Boards 页面的 Archive分区section、即其左侧菜单中的一行。跳转不靠把路径拼写在四处可能漂移的位置而是统一构造FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));URL 构造器位于 allBoardsUrls.jsSECTION_ARCHIVE常量值为archiveALL_BOARDS_SECTIONS是全站唯一一份分区清单含starred、templates、remaining、archiveallBoardsPath(section, slugPath)对非 workspace 分区直接返回${ALL_BOARDS_BASE}/${section}测试中可验证allBoardsPath(SECTION_ARCHIVE, [])严格等于/allboards/archive。这里有两个值得注意的设计细节1Blaze 事件映射的模板边界陷阱。文档专门记录了 All Boards 入口曾经的故障它被渲染出来、点击却毫无反应——因为它依赖的处理器写在标题栏的事件映射里而该映射在为侧边栏重写时消失了。关键机制是Blaze 的事件映射只能看到自己模板内部的事件按钮一旦移出模板处理器就永远不会触发。因此 allBoardsSidebar.js 中渲染它的那份副本必须自带click .js-open-archived-board处理器并且侧边栏入口在跳转前还会调用closeAllBoardsSidebar()关闭面板本身——否则开着的面板会盖住刚刚跳到的归档列表。2不要与看板侧边栏的Archived Items混淆。看板菜单里的js-open-archivesArchived Items打开的是当前看板内部的卡片/列表/泳道归档视图Sidebar.setView(archives)与归档看板页是两条互不重叠的路径测试 archivePage.test.cjs 中有专门断言看板菜单提供 Archived Items 的同时不得误挂js-open-archived-board这个指向 All Boards 归档分区的类名对应上游问题 #1280。3/archive路由仍然保留。文档明确/archive仍是路由、仍会渲染——改造前的旧书签不会失效——只是 UI 中已没有任何按钮再指向它。它被分区取代的原因是全宽页面旁边没有左菜单无法在不折返的情况下横向切到 Starred 或 Remaining而菜单里写着Archive的那一行并不是你实际到达的那一行。菜单条目应该把你带到菜单本身所提供的那个 Archive。/archive路由的实现路由定义在 config/router.js带有一段与文档几乎一致的注释。核心实现// Boards in Archive: a PAGE, not a modal. FlowRouter.route(/archive, { name: archive, triggersEnter: [ ensureSignedInUnlessSandstorm, () { Session.set(currentBoard, null); Session.set(currentList, null); Session.set(currentCard, null); Session.set(popupCardId, null); Session.set(popupCardBoardId, null); Filter.reset(); Session.set(sortBy, ); EscapeActions.executeAll(); }, ], action() { Utils.manageCustomUI(); this.render(defaultLayout, { content: archivedBoards, }); }, });从实现可以看出该页面的三项路由级约定登录守卫ensureSignedInUnlessSandstormSandstorm 沙箱环境下允许匿名进入清空看板会话状态与其他所有非看板页面一致把currentBoard/currentList/currentCard及卡片弹窗状态全部置空并重置过滤器与排序——否则上一个看班会一直处于当前状态单内容渲染defaultLayout只挂archivedBoards一个内容块没有第二标题栏测试断言路由体中不存在headerBar:因为页面名称已经由顶部标题栏统一承担了。关于页面如何被命名文档描述了三步演进弹窗自绘h2弹窗没有标题栏可挂名→ 一度拥有自己独立的第二条标题栏模板archivedBoardsHeaderBar→ 现在由全站统一的顶部标题栏命名否则标题会被打印两遍。以当前仓库代码为准archivePage.test.cjs 断言 boardArchive.jade 中已不存在archivedBoardsHeaderBar模板也断言PAGE_TITLE_KEYS.archive archived-boards该映射位于 models/lib/pageTitles即页面名称现在由顶部标题栏一次性给出与其他页面完全同构。页面本体搜索、服务端分页与恢复页面模板与逻辑分别在 boardArchive.jade 和 boardArchive.js。模板结构template(namearchivedBoards) .table-page-controls input.js-archived-boards-search(typesearch placeholder{{_ search}}) .table-page-pagination button.js-archived-boards-prev-page(class{{#if hasPrevPage}}{{else}}disabled{{/if}}) | lt; span.table-page-page-info {{currentPage}} / {{totalPages}} button.js-archived-boards-next-page(class{{#if hasNextPage}}{{else}}disabled{{/if}}) | gt; ul.archived-lists each archivedBoards li.archived-lists-item div.board-header-btns button.primary.board-header-btn.js-restore-board(typesubmit) i.fa.fa-undo | {{_ restore-board}} title span {{ displayDate archivedAt LLL }} else li.no-items-message {{_ no-archived-boards}}文档同时交代了一条克制的 UI 原则恢复Restore保持可用但不在每个归档看板图标上放破坏性控件——归档瓦片的左下角没有垃圾桶图标、归档图形或像删除一样的悬停控件归档日期只是瓦片内的纯文本。客户端逻辑每页只发布一页boardArchive.js 的onCreated是服务端分页的关键// The apps one rows-per-page (docs/Features/Page/Table.md) const ARCHIVED_BOARDS_PER_PAGE TABLE_PAGE_ROWS_PER_PAGE; Template.archivedBoards.onCreated(function () { this.page new ReactiveVar(1); this.total new ReactiveVar(0); this.searchQuery new ReactiveVar(); this.autorun(() { const searchTerm this.searchQuery.get(); const skip (this.page.get() - 1) * ARCHIVED_BOARDS_PER_PAGE; this.subscribe(archivedBoards, searchTerm, ARCHIVED_BOARDS_PER_PAGE, skip); Meteor.call(getArchivedBoardsCount, searchTerm, (err, count) { if (!err) this.total.set(count || 0); }); }); });三个要点订阅参数是(searchTerm, limit, skip)三元组页码变化或搜索词变化都会触发重新订阅——minimongo 中任何时刻只有当前页数据每页大小复用全站唯一常量TABLE_PAGE_ROWS_PER_PAGEtablePage.js 中定义为10归档页与其他所有分页页面用同一个分页器总页数来自独立的getArchivedBoardsCount方法totalPages由客户端向上取整得出。交互事件同样直接搜索框keydownkeyCode 13Enter才提交搜索并回到第 1 页上一/下一页按钮各自做边界判断后page.set()。恢复按钮的处理器带有一个 Sandstorm 特例——若在 Sandstorm 环境且当前正打开某看板会先把该看板归档再board.restore()恢复目标看板并Utils.goBoardId(board._id)跳转到恢复后的看板。文档的表述是恢复即导航到恢复后的看板。服务端发布权限内建的选择器发布与计数方法位于 server/publications/boards.js// The users active-admin archived boards. #4255: only boards the user can // actually delete (hasAdmin - isActive isAdmin). Paginated (limit/skip) so a // long-lived instances archive never loads every archived board at once. function archivedBoardsSelector(userId, searchTerm) { const selector { archived: true, type: { $nin: [template-container, template-board] }, members: { $elemMatch: { userId, isActive: true, isAdmin: true } }, }; if (searchTerm) { selector.title new RegExp(searchTerm.replace(/[.*?^${}()|[\]\\]/g, \\$), i); } return selector; } Meteor.publish(archivedBoards, async function(searchTerm , limit 30, skip 0) { const userId this.userId; if (!Match.test(userId, String)) return []; check(searchTerm, Match.OneOf(String, null, undefined)); check(limit, Number); check(skip, Match.OneOf(Number, null, undefined)); const ret await ReactiveCache.getBoards( archivedBoardsSelector(userId, searchTerm), { fields: { _id: 1, archived: 1, slug: 1, title: 1, createdAt: 1, modifiedAt: 1, archivedAt: 1, // 瓦片要读的字段颜色、类型、描述、成员、星标…… color: 1, type: 1, description: 1, permission: 1, members: 1, stars: 1 }, sort: { archivedAt: -1, modifiedAt: -1 }, limit, skip: skip || 0, }, true, ); return ret; });从选择器可以读出归档列表的精确语义只含archived: true的看板且排除template-container/template-board两类模板看板权限内建仅返回当前用户活跃管理员isActive isAdmin的看板——这是 #4255 的结论归档页展示的应该是该用户真正能处置的看板搜索词先做正则元字符转义再构造大小写不敏感的RegExp防注入且可解释投影只发送 12 个瓦片实际读取的字段排序固定为archivedAt倒序、modifiedAt倒序兜底——与客户端 helper 的排序一致getArchivedBoardsCount方法有一个值得注意的次序约定先check()参数、后鉴权。源码注释解释Meteor 的参数审计argument auditor要求每个方法参数在被首个await之前完成 check若对未登录调用者提前返回 0 会跳过 check直接抛出 Did not check() all arguments。这个次序后来还反噬过一次All Boards 页面开始从onCreated可能先于用户建立请求该计数才暴露了这条路径。永久删除右侧多选侧边栏中的唯一破坏性操作归档页的另一个保留特性是永久删除它是右侧侧边栏中的多选动作文档给出的四条约束全部有代码对应仅 Global Admin 且开关开启时可见。客户端门控在 allBoardsSidebar.jscanPermanentlyDeleteArchivedBoards()同时要求当前菜单选中SECTION_ARCHIVE、user?.isAdmin true、以及全局设置enablePermanentDelete为真——即管理面板 Problems → Delete 已启用红色 Delete 按钮 不可恢复确认。点击时selectedBoardIdsOrWarn()先取多选集合为空则提示未选择看板再confirm(TAPi18n.__(delete-board-confirm-popup))然后发起 DDP 调用click .js-delete-selected-boards(evt) { evt.preventDefault(); const ids selectedBoardIdsOrWarn(); if (!ids || !confirm(TAPi18n.__(delete-board-confirm-popup))) return; Meteor.call(permanentlyDeleteArchivedBoards, ids, (err) { if (err) { alert(err.reason || err.message || Failed to permanently delete boards); return; } BoardMultiSelection.reset(); }); },注意失败分支不清空选择——文档明确成功的永久删除清空选择被拒绝的保留选择以便管理员纠正设置或选择后重试。服务器独立复核。方法permanentlyDeleteArchivedBoards位于 server/models/boards.js即使面对伪造的 DDP 调用也逐道把关async permanentlyDeleteArchivedBoards(boardIds) { const attemptedIds Array.isArray(boardIds) ? [...new Set(boardIds.filter(id typeof id string))].slice(0, 200) : []; // ... // audit-argument-checks must see the method argument before the first await. check(boardIds, [String]); user this.userId await ReactiveCache.getUser(this.userId); const ids [...new Set(boardIds)]; if (!ids.length || ids.length 200) { throw new Meteor.Error(invalid-board-selection); } const foundBoards await Boards.find( { _id: { $in: ids } }, { fields: { _id: 1, title: 1, archived: 1 } }, ).fetchAsync(); // ... if (user?.isAdmin ! true || !getFeatureFlags().enablePermanentDelete) { throw new Meteor.Error(not-authorized, Permanent delete is disabled.); } if (foundBoards.length ! ids.length || foundBoards.some(board !board.archived)) { throw new Meteor.Error(not-archived, Only archived boards can be permanently deleted.); } for (const board of foundBoards) { await Boards.removeAsync(board._id); await recordRecoveryAudit({ type: RecoveryEvents.types.BOARD_PERMANENTLY_DELETED, user, connection: this.connection, done: true, deletedData: true, boards: [board], detail: Global Admin ${username} (${user._id}) permanently deleted board ${board._id} ..., }); } return { deleted: foundBoards.length }; }服务端复核的完整清单是Global Admin 身份、enablePermanentDelete特性开关、只删已归档看板查到的数量必须与提交数量一致且每个都archived、单次上限 200 个并且先校验整个选择、再删除第一个——一个坏 ID 不能造成部分执行的批量操作。参数审计与审计留痕。check(boardIds, [String])被刻意放在第一个await之前注释解释了原因否则异步的用户查询会让审计上下文认为参数从未被 check掩盖真实结果而catch分支处理参数畸形、身份查询尚未发生的场景——畸形尝试在解析出操作者后仍会写入 RecoveryrecordRecoveryAudit事件类型BOARD_PERMANENTLY_DELETED这与文档畸形尝试在操作者查询后仍会被记录到 Recovery逐句对应。底层普通使用中看板只能通过本方法到达永久删除的约束另见 server/models/boards.js 与 softDelete.js 中canPurge()对Global Admin 永久删除开关的双重要求。多选控制与拖拽目标与 Remaining、Starred、Templates 分区一致Archive 在多选模式激活时于右侧页面看板图标上方显示Select All与Select None且只勾选/取消当前分区与其搜索过滤器实际展示的那些图标——这是文档给出的精确语义对应BoardMultiSelection模块对可见集合的操作Home 分区有同样的控制其 Select All 在存在一个可见 Home 看板时恰好勾选那一个。拖拽归档多选集合时合法落点被高亮为绿色只有Remaining与所有已存在的WorkspaceHome 既不高亮也不接收——归档看板不能成为登录后被打开的看板落到 Workspace的效果是恢复每个看板并把它指派给该 Workspace。回归测试文档的每一条主张都可执行验证设计文档Related files表中列出的测试 tests/archivePage.test.cjs运行方式node tests/archivePage.test.cjs把上述主张全部固化为断言节选其覆盖点/archive是路由、有名字可被链接、渲染在defaultLayout中、内容为archivedBoards、无第二标题栏且PAGE_TITLE_KEYS.archive为archived-boards三个组件目录下没有任何文件再调用Modal.open(archivedBoards)四个入口文件都含click .js-open-archived-board处理器、都用allBoardsPath(SECTION_ARCHIVE, [])构造目标、且都导入了/models/lib/allBoardsUrls绝不手拼/allboards/archive归档页保留了js-archived-boards-search搜索框与上一/下一页按钮订阅签名严格等于this.subscribe(archivedBoards, searchTerm, ARCHIVED_BOARDS_PER_PAGE, skip)且引用了TABLE_PAGE_ROWS_PER_PAGE左侧菜单中 Archive 行在menuSectionOrder()的所有组合里恒为最后一行有星标看板时[starred,remaining,home,templates,archive]否则[remaining,starred,home,templates,archive]Archive 计数来自服务端archivedBoardsCount而非当前页可见项的计数。相关文件文件路径类型说明config/router.js.js路由/archive路由登录守卫、清空看板会话状态、单内容渲染client/components/boards/boardArchive.jade.jade模板archivedBoards页面模板搜索框、分页器、恢复按钮client/components/boards/boardArchive.js.jsBlaze 逻辑搜索提交、分页状态、恢复含 Sandstorm 特例与恢复后跳转server/publications/boards.js.js发布/方法archivedBoards发布权限内建选择器、字段投影、limit/skip与getArchivedBoardsCountserver/models/boards.js.js方法permanentlyDeleteArchivedBoards服务端四道闸门与 Recovery 留痕client/components/boards/allBoardsSidebar.js.js多选侧边栏Delete 按钮门控与 DDP 调用models/lib/allBoardsUrls.js.jsSECTION_ARCHIVE、ALL_BOARDS_SECTIONS、allBoardsPath()models/lib/tablePage.js.jsTABLE_PAGE_ROWS_PER_PAGE 10全站唯一每页行数tests/archivePage.test.cjs.cjsNode 测试验证是页面而非弹窗、四个入口点、搜索与分页在迁移后存活延伸阅读All Boards 页面设计——归档分区的宿主页面四个入口点之一Table 页面设计——归档页共用的每页行数与分页器设计设计文档原文全文事实依据均来自上述仓库文件与 Archive.md 设计文档行号链接基于当前仓库版本若分支更新请以最新代码为准。【免费下载链接】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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进