WeKan 的 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
WeKan 的 All Boards(全部看板)页面内有一个Home(主页)板块:被标记为 Home 的看板会在用户登录后自动打开,替代默认的 All Boards 页面。本文基于 docs/Features/Board/Home.md 这篇设计文档,结合 WeKan 仓库中的 URL 规则模块、看板列表组件、服务端 Meteor 方法与测试用例,完整讲清 Home 板块的交互规则、HTML5 拖放细节与后端约束,读完后你能理解“一个标记字段如何支撑一套登录直达机制”,并知道如何在源码中定位其每一环。
Home 是什么:一个标记,不是一个位置
Home 板块的底层数据是一个一直存在的用户档案字段profile.defaultBoardId。它最早可以通过 All Boards 多选面板(Multi-Selection)中的Set as Home board (opened after login)写入,但那时没有任何界面能“读出”当前 Home 是谁——这个设置是只写的,想确认选了哪个看板只能注销再登录。现在的做法是把 Home 做成All Boards 页面的一个板块,在左侧菜单中与 Starred(收藏)、Templates(模板)并列成一行,回答“到底哪个看板是我的 Home”这个问题。
Home 与 Starred 的本质区别在于数量:
- 任意多个看板都可以被星标;
- 看板中至多一个是 Home(或者一个都没有),因为登录只能打开一个看板。
和星标一样,Home 是打在某个看板上的标记(mark),而不是存放看板的位置:把某个看板设为 Home 不会把它从 Remaining(未分组)或它所在的 workspace 移走,它仍留在原地,同时也会出现在 Home 板块里;取消 Home 也完全不影响它的本来位置。
字段定义:models/users.js
用户集合上围绕该字段封装了三个方法(models/users.js):
getDefaultBoardId():返回profile.defaultBoardId,没有则返回null;isDefaultBoard(boardId):判断某个看板是否就是 Home;toggleDefaultBoard(boardId):是则$unset,否则$set——供多选面板的切换行使用。
字段本身在用户文档 schema 中亦有声明('profile.defaultBoardId'位于 models/users.js 的 schema 定义中)。
左侧菜单中的 Home 行
位置与排序规则
Home 行的位置由纯函数 menuSectionOrder() 决定(位于 models/lib/allBoardsUrls.js):
function menuSectionOrder(hasStarredBoards) { const boardLists = hasStarredBoards ? [SECTION_STARRED, SECTION_REMAINING] : [SECTION_REMAINING, SECTION_STARRED]; return [...boardLists, SECTION_HOME, SECTION_TEMPLATES, SECTION_ARCHIVE]; }规则要点,均被 tests/homeBoard.test.cjs 逐条断言:
- Home 位于两个看板列表(Starred/Remaining)之下、Templates 与 Archive 之上,顺序固定为
[..., 'home', 'templates', 'archive']; - Home 永远不是第一行:页面打开时落在的第一行是 Starred(有星标时)或 Remaining(无星标时),由
defaultSection(hasStarredBoards)决定。理由很实际——登录后用户已经在Home 看板里,此时点开 All Boards 的语义是“看看我的看板”,用他刚刚离开的那一个看板作答毫无意义; - 无论有没有看板在 Home 里,这一行都显示:作为拖放目标的容器必须先于内容存在。
行内的图标、文案与计数
Home 行的元数据在 client/components/boards/boardsList.js 的menuSections()中定义:icon: 'fa-home'、labelKey: 'home'(复用 i18n 里早已存在的home键,已随 WeKan 的全部语言翻译),以及专属类js-home-menu——因为这一行本身还是拖放目标。
行尾的计数显示0或1:
- 计数从
homeBoardId()读profile.defaultBoardId; - 只有当该看板确实属于当前用户可见的看板列表时(
allBoards.some((b) => b._id === id))才计 1。之所以要查“看板还在不在”,是因为一个已删除或已归档的 Home 看板会把过期 id 留在档案里,一行显示1却底下空无一物看起来就是坏了; - 测试同时断言
homeBoardId()只读用户文档、不查看板集合(tests/homeBoard.test.cjs 中 “Home is read from the user document, not from the boards” 用例):若计数依赖看板订阅,菜单就会在订阅追平前反复重排,界面会“抖”。
地址
Home 与其他板块使用同形地址:/allboards/home,由同一套 URL 模块生成(SECTION_HOME = 'home',models/lib/allBoardsUrls.js)。测试确认allBoardsPath('home', [])恰好产出/allboards/home,且手输该地址会解析为自身而不是回落到默认板块。更多地址规则见 All Boards URLs 文档。
Home 里没有“Add Board”瓦片
Home 板块不提供创建看板的入口:在这里新建的看板不会成为登录后打开的那个,瓦片等于许下它兑现不了的承诺。空 Home 显示一行提示文本(i18n 键home-board-empty):
Drag only one board here to open it after login
这句提示在拖放开始之前就把“只收一个”的限制说清了,比一片空白更有用。
拖放交互:设 Home 与撤 Home
拖入:一次拖放,替换而非切换
把任意看板拖到 Home 行上,它即成为登录后打开的看板。All Boards 的看板图标本来就能拖到 Remaining、Archive、workspace 等行上,Home 行只是同一列里多了一个目标,手势不学新东西。对应的 drop 处理在 client/components/boards/boardsList.js 的'drop .js-home-menu'中:
- 直接
Meteor.call('setDefaultBoard', boardIds[0], ...)——替换:Home 只装一个,拖入谁就是谁,不管原来是谁。它刻意不做 toggle:一个有时设、有时清的拖放取决于拖拽过程中读不到的状态,你不可能在拖动时记起 Home 现在是谁; - 与拖放相对的替代入口是多选面板的Set as Home board行,它调用
toggleDefaultBoard,仍然是切换语义——因为那里你是点击一个看得见当前状态的具体看板(client/components/boards/allBoardsSidebar.js); - 多选拖放不会静默取第一个:
boardIds.length !== 1时弹Please select only one board(i18n 键select-only-one-board),不做任何改动,且保留当前选择以便继续收窄,随后直接return,不触及 Home。测试明确断言“拒绝分支必须先于setDefaultBoard调用出现”。
拖出:只能落在 Remove 目标上
从 Home 拖出看板时,它恰好只能落在一个地方——拖拽期间出现的Remove 目标条,其余所有目标一律拒绝。
这个交互借鉴 Android 启动器(launcher):从桌面拿起图标时顶部浮现 Remove 条,图标拖上去时它变红,松手即移除桌面快捷方式——而应用本身还在抽屉里。这里同理:看板仍在 Remaining 或它的 workspace 中,只是不再充当登录后打开的那个。
Remove 条的呈现纪律(client/components/boards/boardsList.jade + client/components/boards/boardsList.js + client/components/boards/boardsList.css):
- 只在 Home 看板真正被拖起时绘制。由
draggingFromHome = new ReactiveVar(false)驱动:dragstart时置true,dragend时清false(覆盖“放下/取消/丢到空白处”三种收尾);模板侧由 helpershowsHomeRemoveTarget()判断“当前选中 Home 板块 &&draggingFromHome.get()”后渲染li.home-remove-target,带垃圾桶图标与文案(i18n 键home-board-remove)。一个在手势可行时才出现的可操作元素是自解释的;一个永远躺在那里的垃圾桶是没人敢按的按钮; - 横跨看板区整行宽度(CSS
flex-basis: 100%)——它是靶子,需要瞄准的靶子必然被瞄偏; - 只有图标悬在其上时才变红:静止状态不含红色(
.home-remove-target规则里没有#c0392b),.home-remove-target.is-over下才有——红色是对“在这里松手会发生什么”的回答,而不是对一个无人触碰的看板的常设警告; - 先问后做:drop 处理先
confirm(TAPi18n.__('home-board-remove-confirm')),确认文案明确说明看板本身不会被删除,之后才Meteor.call('clearDefaultBoard', boardId);点“否”什么都不发生。测试断言 confirm 出现的位置必须先于方法调用。
所有其他目标在 dragover 阶段就拒绝
Remaining、Starred、Templates、Archive 和 workspace 树在dragover中都会检查“这块看板是否来自 Home”,若是则不调用evt.preventDefault()——这正是 HTML5 拖放协议里“拒绝”的表达方式:光标在板子还在空中时就示意不可放,而不是放下之后才安静地什么都不发生。相应 drop 处理器(drop .js-select-menu、drop .js-open-archived-board、drop .workspace-node)里也各有一句if (isDragFromHome(evt)) return;兜底,防某些浏览器仍投递 drop。
为什么标记放在“类型名”里
HTML5 拖放有个硬约束:dragover阶段不能调用getData()。拖放数据存储在 drop 之前处于保护模式,暴露的只有类型名列表。因此“来自 Home”这个事实必须放在类型名本身里:
// client/components/boards/boardsList.js const DRAG_FROM_HOME = 'application/x-board-from-home'; // dragstart(在 Home 中拿起看板时): evt.originalEvent.dataTransfer.setData(DRAG_FROM_HOME, '1'); // dragover 可读的判定: return Array.prototype.indexOf.call(types, DRAG_FROM_HOME) !== -1;该类型在dragstart写入,其值从不被读取——类型的存在本身就是消息。tests/homeBoard.test.cjs 专门断言:isDragFromHome从dataTransfer.types读取、源码中不出现getData(、且每个其他目标的 dragover 拒绝都发生在preventDefault()之前。
文档还记录了一个设计教训:早期版本允许把看板从 Home 拖到其他任意行时“路过即清除 Home”,结果每次拖放都变成一次 Home 拖放——把看板归档进 workspace 时可能意外失去 Home。最终原则是:一个手势,一个目的地,目的地自己说明它将做什么;代码中已不存在clearHomeIfDraggedFromHome这类“顺路清除”函数。
服务端强制的规则
客户端手势之外的最终守门在服务端方法(server/models/users.js):
setDefaultBoard
async setDefaultBoard(boardId) { check(boardId, String); if (!this.userId) throw new Meteor.Error('not-logged-in', 'User must be logged in'); const board = await Boards.findOneAsync({ _id: boardId, archived: false, 'members.userId': this.userId, }); if (!board) throw new Meteor.Error('board-not-found', 'Board not found'); await Users.updateAsync(this.userId, { $set: { 'profile.defaultBoardId': boardId }, }); }- 只接受调用者是成员且未归档的看板。否则用户每次登录都会被重定向到一个打不开、拒绝渲染的看板;
- 查询条件与看板 publication 应用的成员测试一致;
check(boardId, String)必须先于任何return执行(audit-argument-checks 包会检查所有参数都被 check 过);- 语义是无条件替换——与拖放 UI 的“替换而非切换”一致。方法注释里直接点明:这里若用
toggleDefaultBoard的切换语义会引入拖拽者看不见的状态依赖。
clearDefaultBoard
async clearDefaultBoard(boardId) { check(boardId, String); if (!this.userId) throw new Meteor.Error('not-logged-in', 'User must be logged in'); await Users.updateAsync( { _id: this.userId, 'profile.defaultBoardId': boardId }, { $unset: { 'profile.defaultBoardId': '' } }, ); }更新选择器额外匹配'profile.defaultBoardId': boardId:只有当传入的看板确实就是该用户的 Home 时才清除。从别的列表里拖出某个看板,不可能产生“顺手清掉某人 Home”的副作用。
谁来调用这些方法
setDefaultBoard与clearDefaultBoard都由显式手势触发(拖放到 Home 行 / 拖到 Remove 目标并确认)。没有任何自动逻辑会写这里——特别地,Sandstorm 的 auto-open(打开 grain 的单个看板,见 models/lib/sandstormAutoOpen.js)只负责打开,不允许顺带决定该用户从哪个看板开始;tests/sandstormAutoOpen.test.cjs 有负向守卫断言该文件中不出现setDefaultBoard。
登录后的重定向
字段被读取的终点在路由层(config/router.js):登录后解析user.getDefaultBoardId(),存在则将该会话重定向到对应看板(一次/会话)。这也解释了文档开头的设计动机——“每天只在一个看板里干活的人,早上打开 WeKan 就直接在板子里,而不是从列表开始点同一个瓦片”。
相关文档与延伸阅读
- All Boards — Home 所在页面的整体说明
- All Boards URLs —
/allboards/home所在的地址体系 - Archive — 看板可拖入的另一个板块
- Archive and Delete — 归档一个看板到底做了什么
实现文件索引
| 文件路径 | 类型 | 说明 |
|---|---|---|
| models/lib/allBoardsUrls.js | 纯.js模块 | SECTION_HOME常量、menuSectionOrder()(Home 位于两个看板列表之下)、allBoardsPath()、sectionTitleKey()(标题与菜单高亮共用home键) |
| client/components/boards/boardsList.js | 客户端组件 | Home 行、0/1 计数、板块过滤(sel === 'home'时只列那一个看板)、DRAG_FROM_HOME、draggingFromHome、全部设/清 Home 的拖放路径 |
| client/components/boards/boardsList.jade | .jade模板 | 菜单行、Remove 条(home-remove-target+ 垃圾桶图标 +home-board-remove文案)、空 Home 提示 |
| client/components/boards/boardsList.css | .css | Remove 条整行铺满(flex-basis: 100%)、静止不红、.is-over时#c0392b |
| client/components/boards/allBoardsSidebar.js | 客户端组件 | 多选面板里独立的Set as Home board行,调用toggleDefaultBoard(切换语义) |
| models/users.js | 用户集合 | getDefaultBoardId、isDefaultBoard、toggleDefaultBoard |
| server/models/users.js | 服务端方法 | setDefaultBoard(替换、成员校验)与clearDefaultBoard(只清自己的 Home) |
| config/router.js | 路由 | 登录后每会话一次的 Home 重定向 |
| tests/homeBoard.test.cjs | Node 测试 | 排序、计数、拖放、Remove 条与服务端拒绝的全套断言 |
| tests/defaultBoard.test.cjs | Node 测试 | toggleDefaultBoard的档案更新语义与鉴权守卫 |
【免费下载链接】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),仅供参考