"use client"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import boardData from "../data/kanban-board.json"; import styles from "./KanbanBoard.module.css"; type Card = { id: string; title: string; description: string; priority: string; tags: string[]; owner: { name: string; initial: string; tone: string }; dueDate: string; checklist: { done: number; total: number }; comments: number; attachments: number; }; type Lane = { id: string; title: string; color: string; limit: number; cards: Card[] }; type Board = { id: string; name: string; description: string; updatedAt: string; lanes: Lane[] }; type Activity = { id: number; card: string; from: string; to: string; time: string }; const initialBoard = boardData as Board; export default function KanbanPage() { const [board, setBoard] = useState(initialBoard); const [dragging, setDragging] = useState<{ cardId: string; laneId: string } | null>(null); const [dropLane, setDropLane] = useState(null); const [search, setSearch] = useState(""); const [priority, setPriority] = useState("全部优先级"); const [owner, setOwner] = useState("全部成员"); const [selectedCard, setSelectedCard] = useState<{ card: Card; laneId: string } | null>(null); const [createOpen, setCreateOpen] = useState(false); const [laneOpen, setLaneOpen] = useState(false); const [activityOpen, setActivityOpen] = useState(false); const [toast, setToast] = useState(""); const [activities, setActivities] = useState([ { id: 1, card: "WEB-119", from: "进行中", to: "待验收", time: "今天 14:26" }, { id: 2, card: "WEB-108", from: "待验收", to: "已完成", time: "今天 11:08" }, ]); useEffect(() => { const saved = window.localStorage.getItem("orbit-kanban-board"); if (saved) { try { setBoard(JSON.parse(saved)); } catch { window.localStorage.removeItem("orbit-kanban-board"); } } }, []); useEffect(() => { window.localStorage.setItem("orbit-kanban-board", JSON.stringify(board)); }, [board]); const allCards = useMemo(() => board.lanes.flatMap((lane) => lane.cards.map((card) => ({ card, laneId: lane.id }))), [board]); const owners = useMemo(() => Array.from(new Set(allCards.map(({ card }) => card.owner.name))), [allCards]); const completed = board.lanes.find((lane) => lane.id === "done")?.cards.length ?? 0; const notify = (message: string) => { setToast(message); window.setTimeout(() => setToast(""), 2300); }; const moveCard = (cardId: string, fromLaneId: string, targetLaneId: string) => { if (fromLaneId === targetLaneId) { setDragging(null); setDropLane(null); return; } const target = board.lanes.find((lane) => lane.id === targetLaneId); if (!target) return; if (target.cards.length >= target.limit) { notify(`“${target.title}”已达到 ${target.limit} 张卡片上限`); setDragging(null); setDropLane(null); return; } const from = board.lanes.find((lane) => lane.id === fromLaneId); const card = from?.cards.find((item) => item.id === cardId); if (!card || !from) return; setBoard((current) => ({ ...current, updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }), lanes: current.lanes.map((lane) => { if (lane.id === fromLaneId) return { ...lane, cards: lane.cards.filter((item) => item.id !== cardId) }; if (lane.id === targetLaneId) return { ...lane, cards: [...lane.cards, card] }; return lane; }), })); setActivities((current) => [{ id: Date.now(), card: card.id, from: from.title, to: target.title, time: "刚刚" }, ...current].slice(0, 8)); setSelectedCard((current) => current?.card.id === cardId ? { card, laneId: targetLaneId } : current); setDragging(null); setDropLane(null); notify(`已将 ${card.id} 移动到“${target.title}”`); }; const createCard = (title: string, laneId: string, cardPriority: string) => { const lane = board.lanes.find((item) => item.id === laneId); if (!lane || lane.cards.length >= lane.limit) return notify("目标泳道已达到卡片上限"); const card: Card = { id: `WEB-${String(Date.now()).slice(-3)}`, title, description: "点击卡片可继续补充任务说明", priority: cardPriority, tags: ["新任务"], owner: { name: "林知夏", initial: "林", tone: "blue" }, dueDate: "08-21", checklist: { done: 0, total: 4 }, comments: 0, attachments: 0 }; setBoard((current) => ({ ...current, lanes: current.lanes.map((item) => item.id === laneId ? { ...item, cards: [...item.cards, card] } : item) })); setCreateOpen(false); notify("新卡片已添加"); }; const createLane = (title: string, limit: number) => { const colors = ["blue", "purple", "orange", "green", "gray"]; setBoard((current) => ({ ...current, lanes: [...current.lanes, { id: `lane-${Date.now()}`, title, limit, color: colors[current.lanes.length % colors.length], cards: [] }] })); setLaneOpen(false); notify("新泳道已创建"); }; const resetBoard = () => { setBoard(initialBoard); window.localStorage.removeItem("orbit-kanban-board"); notify("已恢复 JSON 示例看板"); }; return (
setBoard((current) => ({ ...current, name: event.target.value }))} aria-label="看板名称" />{board.description}
团队可见

SPRINT 08 · 8 月 4 日 — 8 月 18 日

产品迭代泳道

拖动卡片即可更新工作状态,所有变更自动保存在浏览器。
{allCards.length}全部卡片
{board.lanes.find((lane) => lane.id === "doing")?.cards.length ?? 0}进行中
{completed}已完成
{Math.round((completed / allCards.length) * 100)}%迭代完成率
+5
最近更新:{board.updatedAt}
{board.lanes.map((lane) => { const visible = lane.cards.filter((card) => (!search || `${card.title}${card.id}`.toLowerCase().includes(search.toLowerCase())) && (priority === "全部优先级" || card.priority === priority) && (owner === "全部成员" || card.owner.name === owner)); const atLimit = lane.cards.length >= lane.limit; return (
{ event.preventDefault(); setDropLane(lane.id); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) setDropLane(null); }} onDrop={(event) => { event.preventDefault(); if (dragging) moveCard(dragging.cardId, dragging.laneId, lane.id); }}>
{lane.title}{lane.cards.length}{atLimit && lane.limit < 90 && 已达上限}
WIP {lane.cards.length} / {lane.limit}
{visible.map((card) => setDragging({ cardId: card.id, laneId: lane.id })} onDragEnd={() => { setDragging(null); setDropLane(null); }} onOpen={() => setSelectedCard({ card, laneId: lane.id })} />)} {visible.length === 0 &&
{dragging ? "松开鼠标移动到这里" : "暂无匹配卡片"}
}
); })}
{selectedCard && setSelectedCard(null)} onMove={(target) => moveCard(selectedCard.card.id, selectedCard.laneId, target)} notify={notify} />} {createOpen && setCreateOpen(false)} onCreate={createCard} />} {laneOpen && setLaneOpen(false)} onCreate={createLane} />} {activityOpen && setActivityOpen(false)} />} {toast &&
{toast}
}
); } function KanbanCard({ card, laneId, dragging, onDragStart, onDragEnd, onOpen }: { card: Card; laneId: string; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onOpen: () => void }) { const percent = Math.round((card.checklist.done / card.checklist.total) * 100); return
{ event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", `${laneId}:${card.id}`); onDragStart(); }} onDragEnd={onDragEnd} onClick={onOpen}>
{card.priority === "高" ? "↑ 高" : card.priority === "低" ? "↓ 低" : "= 中"}
{card.id}

{card.title}

{card.description}

{card.tags.map((tag) => {tag})}
检查项{card.checklist.done}/{card.checklist.total}
{card.owner.initial}{card.owner.name}◷ {card.dueDate}☵ {card.comments}⌕ {card.attachments}
; } function CardDrawer({ selected, lanes, onClose, onMove, notify }: { selected: { card: Card; laneId: string }; lanes: Lane[]; onClose: () => void; onMove: (laneId: string) => void; notify: (message: string) => void }) { const { card, laneId } = selected; return
; } function CardModal({ lanes, onClose, onCreate }: { lanes: Lane[]; onClose: () => void; onCreate: (title: string, laneId: string, priority: string) => void }) { const [title, setTitle] = useState(""); const [laneId, setLaneId] = useState(lanes[0]?.id ?? ""); const [priority, setPriority] = useState("中"); return
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (title.trim()) onCreate(title.trim(), laneId, priority); }}>

新建看板卡片

创建后可直接拖动到其他泳道。

优先级{["高", "中", "低"].map((value) => )}
; } function LaneModal({ onClose, onCreate }: { onClose: () => void; onCreate: (title: string, limit: number) => void }) { const [title, setTitle] = useState(""); const [limit, setLimit] = useState(5); return
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (title.trim()) onCreate(title.trim(), limit); }}>

新建泳道

设置泳道名称和在制品上限。

; } function ActivityDrawer({ activities, onClose }: { activities: Activity[]; onClose: () => void }) { return
; }