跳转至

看板

按状态分列的板:卡片所在列就是它的状态,把卡拖到别的列 = 变更状态。任务、商机、工单这类有明确阶段流转(管道/分诊)的数据,状态是主轴时用它。

试这几下:

  • 一张卡到别的列 —— 悬停列会高亮接收区,放手即变更状态(toast 确认)
  • 不方便拖就点卡片上的 ◀ ▶ —— 键盘也可达,效果等价
  • 列头实时汇总本列单数与金额

规矩

  • 一个状态枚举驱动列;移动卡片必须持久化新状态 —— 真实版在放手时乐观调用 Repository.update(cardId, { status: to }),失败回滚 + toast
  • 指针与键盘双可达:蓝本用 dnd-kit(指针 + 键盘 + 列内排序 + 拖拽浮层),demo 用平台原生 HTML5 DnD + 箭头按钮近似最小实现;要列内排序、插入间隙这些完整体验,用蓝本模板
  • 记录带排序/位置字段时,列内顺序也要持久化

蓝本:add-kanban.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-kanban.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:看板</title>
<!--
  demo-kanban.html —— 「看板」形状的最小可玩实现(蓝本:open-dashboard 的 add-kanban)。
  复现的不变量:
  - 一个状态枚举驱动列;卡片所在列**就是**它的状态;跨列移动 = 持久化状态变更(此处更新内存 state + toast)
  - 指针 + 键盘双可达:整卡可拖(HTML5 DnD),◀ ▶ 按钮等价移动(蓝本用 dnd-kit,此处用平台原生能力近似)
  - 拖拽悬停的列有可见的接收反馈
  简化:新卡追加列尾;列内排序(真实版由 dnd-kit 提供)不在最小实现里。
  运行时:/vendor 的 React 18 UMD + htm,样式共用 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; align-items: start; }
  .col { padding: 10px; min-height: 300px; }
  .col.over { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 6%, var(--card)); }
  .col h2 { margin: 0 0 8px; font-size: 13px; color: var(--muted); font-weight: 600;
            display: flex; justify-content: space-between; }
  .kcard { border: 1px solid var(--border); border-radius: 8px; background: var(--bg);
           padding: 8px 10px; margin-bottom: 8px; cursor: grab; }
  .kcard:active { cursor: grabbing; }
  .kcard .t { margin: 0; font-weight: 550; font-size: 13px; }
  .kcard .m { margin: 2px 0 6px; color: var(--muted); font-size: 12px; }
  .kcard .mv { display: flex; justify-content: space-between; }
  .kcard.dragging { opacity: .4; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useEffect } = React;
const html = htm.bind(React.createElement);

const COLUMNS = [
  { key: "todo",  label: "待跟进" },
  { key: "doing", label: "洽谈中" },
  { key: "done",  label: "已成交" },
];
const SEED = [
  { id: 1, title: "云资源年度续约",   amount: 128000, owner: "陈禾",   status: "todo"  },
  { id: 2, title: "数据平台 POC",     amount: 45000,  owner: "李慕白", status: "todo"  },
  { id: 3, title: "客服系统二期",     amount: 220000, owner: "王小满", status: "doing" },
  { id: 4, title: "BI 报表定制",      amount: 38000,  owner: "赵青",   status: "doing" },
  { id: 5, title: "安全审计服务",     amount: 96000,  owner: "周芷",   status: "doing" },
  { id: 6, title: "官网改版",         amount: 52000,  owner: "吴限",   status: "done"  },
  { id: 7, title: "小程序商城",       amount: 87000,  owner: "郑舟",   status: "todo"  },
];

function App() {
  const [cards, setCards] = useState(SEED);
  const [dragId, setDragId] = useState(null);
  const [overCol, setOverCol] = useState(null);
  const [toast, setToast] = useState("");

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(""), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  // 移动 = 改状态(真实版在这里调 Repository.update(cardId, { status: to }) 并做乐观更新)
  const move = (id, to) => {
    const card = cards.find(c => c.id === id);
    if (!card || card.status === to) return;
    setCards(cs => cs.map(c => (c.id === id ? { ...c, status: to } : c)));
    setToast(`「${card.title}」→ ${COLUMNS.find(c => c.key === to).label}`);
  };
  const step = (id, dir) => {
    const card = cards.find(c => c.id === id);
    const i = COLUMNS.findIndex(c => c.key === card.status) + dir;
    if (i >= 0 && i < COLUMNS.length) move(id, COLUMNS[i].key);
  };

  const money = n => "¥ " + n.toLocaleString("zh-CN");

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>商机看板</h1>
          <p class="sub">拖卡片到别的列,或用 ◀ ▶ 移动 —— 列就是状态,移动即状态变更</p>
        </div>
      </header>

      <div class="board">
        ${COLUMNS.map(col => {
          const list = cards.filter(c => c.status === col.key);
          return html`
            <div key=${col.key} class=${"card col" + (overCol === col.key ? " over" : "")}
                 onDragOver=${e => { e.preventDefault(); setOverCol(col.key); }}
                 onDragLeave=${() => setOverCol(null)}
                 onDrop=${e => { e.preventDefault(); setOverCol(null); if (dragId != null) move(dragId, col.key); }}>
              <h2><span>${col.label}</span>
                  <span>${list.length} 单 · ${money(list.reduce((s, c) => s + c.amount, 0))}</span></h2>
              ${list.map(c => html`
                <div key=${c.id} class=${"kcard" + (dragId === c.id ? " dragging" : "")}
                     draggable="true"
                     onDragStart=${() => setDragId(c.id)}
                     onDragEnd=${() => { setDragId(null); setOverCol(null); }}>
                  <p class="t">${c.title}</p>
                  <p class="m">${money(c.amount)} · ${c.owner}</p>
                  <div class="mv">
                    <button class="link" aria-label="移到左列" disabled=${c.status === COLUMNS[0].key}
                            onClick=${() => step(c.id, -1)}>◀</button>
                    <button class="link" aria-label="移到右列" disabled=${c.status === COLUMNS[COLUMNS.length - 1].key}
                            onClick=${() => step(c.id, 1)}>▶</button>
                  </div>
                </div>`)}
              ${list.length === 0 && html`<p class="empty">拖一张卡片进来</p>`}
            </div>`;
        })}
      </div>

      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>