有些项目不是为了发布,而是为了练手;有些代码不是为了交付,而是为了成长。而我今天要讲的这个 Kanban 看板,就是这样一个陪伴我成长、让我抓头又让我骄傲的项目。

一、灵感来自生活的混乱

那天,我盯着浏览器里无数个 Tab,看着 Notion 上堆积如山的 TODO 清单,感觉自己的生活像是一个没打扫的桌面 —— 到处是文件夹,到处是“稍后再看”的标签。于是我灵机一动:为什么不自己写一个看板,把所有的事情视觉化地管理起来?

我想要的并不复杂:

  • 有三个状态:待办进行中已完成
  • 卡片可以自由拖动
  • 状态变化能够保存下来,刷新页面也不会丢
  • UI 要简洁、现代、有呼吸感(别像是 Excel 表格)

就这样,我打开了 VS Code,一行行敲下了这个看板的起点。


二、界面设计:现代简约风,看得见的灵感

我没有使用什么设计稿工具,直接在浏览器里调试着写 CSS。但这一次,我决定不再用混乱的全局 CSS,而是上了 CSS Modules —— 每个组件一套自己的样式,不互相干扰,用起来也舒服。

整个 UI 的布局大致如下:

用一张看板,把生活理顺_看板

每个列是一个组件,每张卡片也是一个组件。布局采用 Flex 实现响应式设计,在大屏和小屏之间切换都很自然。颜色方面我选用了低饱和度的蓝灰色调,配合一点点圆角和微妙的阴影,整体观感舒适不压抑。


三、技术选型与项目初始化

这是一个纯前端项目,为了让代码模块化、便于维护,我选用了:

  • React 18 + Vite:构建快、开发体验丝滑
  • CSS Modules:告别全局样式污染
  • 原生 Drag & Drop API:避免引入沉重的拖拽库,轻量清爽
  • localStorage:实现本地持久化

项目初始化非常简单,一条命令:

npm create vite@latest kanban-board -- --template react
cd kanban-board
npm install
  • 1.
  • 2.
  • 3.

然后我清空了默认模板,开始创建组件。


四、实现拖拽逻辑:一步步走进浏览器的拖拽机制

拖拽的灵魂其实很简单,主要依赖浏览器内建的 dragstartdragoverdrop 等事件。但实现过程中,细节比想象中多得多。

我先定义了一个 Card 组件,它的核心代码看起来像这样:

function Card({ card, onDragStart }: { card: CardType, onDragStart: any }) {
  return (
    <div
      className={styles.card}
      draggable
      onDragStart={(e) => onDragStart(e, card.id)}
    >
      {card.title}
    </div>
  );
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

关键在 draggableonDragStart。这时候我需要记住拖的是哪一张卡片,因此我在 dragStart 的回调中保存了当前卡片的 ID。

而每个列组件则需要接收拖拽进入的卡片,并决定是否接受“放下”:

function Column({ status, cards, onDropCard }: ColumnProps) {
  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    const cardId = e.dataTransfer.getData("cardId");
    onDropCard(cardId, status);
  };

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault();
  };

  return (
    <div
      className={styles.column}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
    >
      <h2>{status}</h2>
      {cards.map((card) => (
        <Card key={card.id} card={card} onDragStart={...} />
      ))}
    </div>
  );
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.

拖拽中最关键的一点是 “要阻止默认行为”,否则浏览器可能会把你的卡片当成文件来处理……

实现完拖拽,我试着把一张卡片从“待办”拖到“进行中”,它果然“滑”过去了!


五、状态管理:用 useState 管住整个世界

为了管理所有卡片的状态,我在 App 中使用了 useState,并将 cards 结构设计为:

type CardType = {
  id: string;
  title: string;
  status: 'todo' | 'doing' | 'done';
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

我封装了一个 moveCard 函数,每当 drop 事件发生,就更新目标卡片的状态。

const moveCard = (cardId: string, newStatus: string) => {
  setCards(prev =>
    prev.map(card =>
      card.id === cardId ? { ...card, status: newStatus } : card
    )
  );
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

这个函数简单得不能再简单,但却是整个看板流转的核心。


六、持久化数据:localStorage,小而美的解决方案

页面刷新后,数据还在吗?

起初当然是不在。于是我加了 localStorage 的支持,用 useEffect 实现了加载和保存逻辑。

useEffect(() => {
  const saved = localStorage.getItem("kanban-cards");
  if (saved) {
    setCards(JSON.parse(saved));
  }
}, []);

useEffect(() => {
  localStorage.setItem("kanban-cards", JSON.stringify(cards));
}, [cards]);
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

到这一步,我的卡片可以自由拖动,状态自动保存,UI 美观舒适。我坐在屏幕前,看着这些小卡片整齐地排列在各自的列中,心里充满了成就感。


七、多看板切换:刷新思路的小锦囊

用着用着,我发现自己同时想管理学习任务和工作任务,又或者是生活中偶尔蹦出的点子。这时候,给项目加上 多看板切换 功能,就显得格外有必要。于是,我给数据结构里再包一层:

type BoardType = {
  id: string;
  name: string;
  cards: CardType[];
};

type AppState = {
  boards: BoardType[];
  activeBoardId: string;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

想法很简单:在最上面放一个看板列表,点击名字就切换。当页面加载时,我先从 localStorage 中读入 boards,再根据 activeBoardId 渲染当前看板。

const [state, setState] = useState<AppState>(() => {
  const saved = localStorage.getItem('kanban-state');
  return saved ? JSON.parse(saved) : {
    boards: [{ id: '1', name: '默认看板', cards: [] }],
    activeBoardId: '1'
  };
});

useEffect(() => {
  localStorage.setItem('kanban-state', JSON.stringify(state));
}, [state]);
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

切换看板只要更新 activeBoardId 即可:

function switchBoard(id: string) {
  setState(prev => ({ ...prev, activeBoardId: id }));
}
  • 1.
  • 2.
  • 3.

在界面上,我在标题右侧设计了一个下拉菜单,点开后列出所有看板。流程大致长这样:

用一张看板,把生活理顺_CSS_02

当我第一次实现这部分时,下拉菜单点开后,看板名称覆盖在卡片上。有趣的是,这个问题让我想起了 CSS 层级(z-index)的小坑。我迅速给下拉做了一个 z-index: 1000,而看板列的 z-index 只要保持默认就好,瞬间解决。就是这些看似不起眼的小细节,让我越写越上瘾。


八、卡片详情弹窗:信息展示与交互的最佳实践

长按卡片或者点击卡片上的「…」按钮,我希望弹出一个详情弹窗,让用户能看到更多信息,比如描述、截止日期、标签,甚至评论。于是,我先在 CardType 里加上新字段:

type CardType = {
  id: string;
  title: string;
  status: Status;
  description?: string;
  dueDate?: string;
  tags?: string[];
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
弹窗组件的设计思路

我的 Modal 组件非常简单,不引入额外库,用纯 React 实现。大致结构是一个半透明遮罩层,里头是一个居中的内容容器。遮罩层点击时,会自动关闭弹窗。

function Modal({ visible, onClose, children }: ModalProps) {
  if (!visible) return null;
  return (
    <div className={styles.modalOverlay} onClick={onClose}>
      <div className={styles.modalContent} onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

这里的关键在于 stopPropagation():防止点击内容区域时触发遮罩层的 onClick,导致弹窗莫名其妙地关闭掉!

在样式里,我用了 position: fixed 让弹窗始终保持在视口正中,用 display: flex; align-items: center; justify-content: center; 来垂直水平居中。弹窗背景是纯白色,四周留有足够的内边距,还加了圆角和轻微的阴影,让它“悬浮”在看板之上。

打开与关闭流程

梳理整个弹窗逻辑流程:

用一张看板,把生活理顺_看板_03

当我第一次测试时,发现点击弹窗内部的按钮依然会关闭,这就是没有加 stopPropagation() 的后果。修复后,一切正常。


九、标签筛选:让信息不过载

随着卡片增多,有时候想只看特定类型的任务,比如“前端” 或者 “设计”。标签功能就派上用场了。我给每张卡片都关联一个字符串数组 tags,例如 ['frontend', 'urgent']。界面上,我在看板顶部再加一个标签筛选条,展示所有已存在的标签,点击某个标签,就只保留带有该标签的卡片。

实现逻辑其实也很直观:把 cards 先根据 status 分类,然后再每个列里做一次 filter(card => card.tags.includes(activeTag))。我把这个筛选操作封装成一个 Hook,命名为 useFilteredCards

function useFilteredCards(cards: CardType[], status: Status, activeTag?: string) {
  return useMemo(() => {
    return cards
      .filter(card => card.status === status)
      .filter(card => !activeTag || card.tags?.includes(activeTag));
  }, [cards, status, activeTag]);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

这样,列组件接收的 cards 就是已经处理好、按需展示的内容。我还实现了“清除筛选”按钮,一键恢复全部卡片的视图。

在实现标签列表时,我注意到了 React 渲染 key 的选择:如果直接用标签名称当 key,多个看板里可能会重复。于是,我拼接了看板 ID 和标签名,保证 key 的唯一性。每次点击某个标签,就调用 setActiveTag(tag);再次点击时,如果该标签已经处于激活状态,就清空 activeTag


十、UI 细节打磨:让设计更有温度

在做完功能之后,我开始关注一些小细节。比如:

  • 过渡动画:卡片拖动时,列背景从浅灰变为淡蓝,给用户一点视觉反馈;弹窗打开时,加一个缩放渐变。
  • 响应式布局:在窄屏幕下,三列会变成可横向滚动的卡片列表;在超大屏上,列宽也不会无限拉长,用最大宽度限制在 400px。
  • 暗黑模式:我加了一个主题切换按钮,data-theme 属性切换后,所有颜色都基于 CSS 变量调整。白天晚上都能舒服地看板。

Snippet 小片段来感受一下动画处理:

.column {
  transition: background-color 0.3s ease;
}
.column.drag-over {
  background-color: var(--drag-over-bg);
}
.card {
  transition: transform 0.2s ease;
}
.card:active {
  transform: scale(1.02);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.