199 lines
18 KiB
TypeScript
199 lines
18 KiB
TypeScript
"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<Board>(initialBoard);
|
||
const [dragging, setDragging] = useState<{ cardId: string; laneId: string } | null>(null);
|
||
const [dropLane, setDropLane] = useState<string | null>(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<Activity[]>([
|
||
{ 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 (
|
||
<div className={styles.shell}>
|
||
<aside className={styles.sidebar}>
|
||
<Link href="/" className={styles.brand}><span className={styles.brandMark}><i /><i /><i /></span><strong>Orbit<span>Flow</span></strong></Link>
|
||
<nav><p>工作台</p><Link href="/"><span>⌂</span>概览</Link><Link href="/worklist"><span>☷</span>工作列表</Link><a className={styles.active}><span>▦</span>泳道看板<b>{allCards.length}</b></a><Link href="/approval"><span>⌘</span>审批流程设计</Link><p>快捷视图</p><a><span>☆</span>我的卡片</a><a><span>!</span>即将到期</a><a><span>✓</span>本周完成</a></nav>
|
||
<div className={styles.sidebarProject}><span>W</span><div><strong>官网 3.0</strong><small>产品研发空间</small></div><button>⌄</button></div>
|
||
</aside>
|
||
|
||
<main className={styles.main}>
|
||
<header className={styles.topbar}>
|
||
<div className={styles.boardIdentity}><div><span className={styles.boardIcon}>▦</span><div><input value={board.name} onChange={(event) => setBoard((current) => ({ ...current, name: event.target.value }))} aria-label="看板名称" /><small>{board.description}</small></div><b>团队可见</b></div></div>
|
||
<div className={styles.topActions}><button onClick={() => setActivityOpen(true)}>↻ 动态</button><button onClick={() => notify("分享链接已生成(模板演示)")}>♙ 分享</button><span className={styles.avatar}>林</span></div>
|
||
</header>
|
||
|
||
<section className={styles.content}>
|
||
<div className={styles.heading}>
|
||
<div><p>SPRINT 08 · 8 月 4 日 — 8 月 18 日</p><h1>产品迭代泳道</h1><span>拖动卡片即可更新工作状态,所有变更自动保存在浏览器。</span></div>
|
||
<div><button className={styles.secondaryButton} onClick={resetBoard}>↺ 重置示例</button><button className={styles.secondaryButton} onClick={() => setLaneOpen(true)}>+ 新建泳道</button><button className={styles.primaryButton} onClick={() => setCreateOpen(true)}>+ 新建卡片</button></div>
|
||
</div>
|
||
|
||
<div className={styles.boardStats}>
|
||
<div><strong>{allCards.length}</strong><span>全部卡片</span></div><i /><div><strong>{board.lanes.find((lane) => lane.id === "doing")?.cards.length ?? 0}</strong><span>进行中</span></div><i /><div><strong>{completed}</strong><span>已完成</span></div><i /><div><strong>{Math.round((completed / allCards.length) * 100)}%</strong><span>迭代完成率</span></div>
|
||
<div className={styles.memberStack}><span className={styles.blue}>林</span><span className={styles.purple}>周</span><span className={styles.green}>唐</span><span className={styles.orange}>许</span><b>+5</b></div>
|
||
</div>
|
||
|
||
<div className={styles.toolbar}>
|
||
<label><span>⌕</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索卡片标题或编号" /></label>
|
||
<select value={priority} onChange={(event) => setPriority(event.target.value)}><option>全部优先级</option><option>高</option><option>中</option><option>低</option></select>
|
||
<select value={owner} onChange={(event) => setOwner(event.target.value)}><option>全部成员</option>{owners.map((name) => <option key={name}>{name}</option>)}</select>
|
||
<button onClick={() => { setSearch(""); setPriority("全部优先级"); setOwner("全部成员"); }}>清除筛选</button>
|
||
<span>最近更新:{board.updatedAt}</span>
|
||
</div>
|
||
|
||
<div className={styles.boardScroller}>
|
||
<div className={styles.board}>
|
||
{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 (
|
||
<section key={lane.id} className={`${styles.lane} ${dropLane === lane.id ? styles.dropActive : ""}`} onDragOver={(event) => { 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); }}>
|
||
<header><div><span className={`${styles.laneDot} ${styles[lane.color]}`} /><strong>{lane.title}</strong><b>{lane.cards.length}</b>{atLimit && lane.limit < 90 && <em>已达上限</em>}</div><button onClick={() => notify(`“${lane.title}”泳道设置为模板占位`)}>•••</button></header>
|
||
<div className={styles.laneLimit}><span><i style={{ width: `${Math.min(100, (lane.cards.length / lane.limit) * 100)}%` }} /></span><small>WIP {lane.cards.length} / {lane.limit}</small></div>
|
||
<div className={styles.cards}>
|
||
{visible.map((card) => <KanbanCard key={card.id} card={card} laneId={lane.id} dragging={dragging?.cardId === card.id} onDragStart={() => setDragging({ cardId: card.id, laneId: lane.id })} onDragEnd={() => { setDragging(null); setDropLane(null); }} onOpen={() => setSelectedCard({ card, laneId: lane.id })} />)}
|
||
{visible.length === 0 && <div className={styles.emptyLane}><span>{dragging ? "松开鼠标移动到这里" : "暂无匹配卡片"}</span></div>}
|
||
<button className={styles.addCard} onClick={() => setCreateOpen(true)}>+ 添加卡片</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
<button className={styles.addLane} onClick={() => setLaneOpen(true)}><span>+</span>添加泳道</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
{selectedCard && <CardDrawer selected={selectedCard} lanes={board.lanes} onClose={() => setSelectedCard(null)} onMove={(target) => moveCard(selectedCard.card.id, selectedCard.laneId, target)} notify={notify} />}
|
||
{createOpen && <CardModal lanes={board.lanes} onClose={() => setCreateOpen(false)} onCreate={createCard} />}
|
||
{laneOpen && <LaneModal onClose={() => setLaneOpen(false)} onCreate={createLane} />}
|
||
{activityOpen && <ActivityDrawer activities={activities} onClose={() => setActivityOpen(false)} />}
|
||
{toast && <div className={styles.toast}><span>✓</span>{toast}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 <article className={`${styles.card} ${dragging ? styles.dragging : ""}`} draggable onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", `${laneId}:${card.id}`); onDragStart(); }} onDragEnd={onDragEnd} onClick={onOpen}><div className={styles.cardTop}><span className={`${styles.priority} ${styles[`priority_${card.priority}`]}`}>{card.priority === "高" ? "↑ 高" : card.priority === "低" ? "↓ 低" : "= 中"}</span><button onClick={(event) => { event.stopPropagation(); onOpen(); }}>•••</button></div><small className={styles.cardId}>{card.id}</small><h3>{card.title}</h3><p>{card.description}</p><div className={styles.tags}>{card.tags.map((tag) => <span key={tag}>{tag}</span>)}</div><div className={styles.checklist}><div><span>检查项</span><b>{card.checklist.done}/{card.checklist.total}</b></div><span><i style={{ width: `${percent}%` }} /></span></div><footer><span className={`${styles.ownerAvatar} ${styles[card.owner.tone]}`}>{card.owner.initial}</span><strong>{card.owner.name}</strong><span className={card.dueDate === "今天" ? styles.today : ""}>◷ {card.dueDate}</span><i>☵ {card.comments}</i><i>⌕ {card.attachments}</i></footer></article>;
|
||
}
|
||
|
||
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 <div className={styles.drawerBackdrop} onMouseDown={onClose}><aside className={styles.drawer} onMouseDown={(event) => event.stopPropagation()}><header><div><small>{card.id}</small><h2>{card.title}</h2></div><button onClick={onClose}>×</button></header><div className={styles.drawerBody}><label className={styles.moveSelect}><span>当前泳道</span><select value={laneId} onChange={(event) => onMove(event.target.value)}>{lanes.map((lane) => <option key={lane.id} value={lane.id}>移动到:{lane.title}</option>)}</select></label><section><h3>任务说明</h3><p className={styles.descriptionBox}>{card.description}</p></section><section><h3>任务属性</h3><dl><div><dt>负责人</dt><dd><span className={`${styles.ownerAvatar} ${styles[card.owner.tone]}`}>{card.owner.initial}</span>{card.owner.name}</dd></div><div><dt>优先级</dt><dd><span className={`${styles.priority} ${styles[`priority_${card.priority}`]}`}>{card.priority}</span></dd></div><div><dt>截止时间</dt><dd>{card.dueDate}</dd></div><div><dt>标签</dt><dd>{card.tags.join("、")}</dd></div></dl></section><section><h3>检查项</h3>{["完成设计稿评审", "补充空状态", "完成开发自测", "产品验收"].slice(0, card.checklist.total).map((item, index) => <label className={styles.checkItem} key={item}><input type="checkbox" defaultChecked={index < card.checklist.done} onChange={() => notify("检查项已更新(演示)")} /><span>{item}</span></label>)}</section><section><h3>动态</h3><div className={styles.activityItem}><span className={`${styles.ownerAvatar} ${styles.blue}`}>林</span><p><strong>林知夏</strong> 更新了卡片信息<small>今天 14:32</small></p></div></section></div><footer><button onClick={onClose}>关闭</button><button onClick={() => notify("编辑卡片为模板交互")}>编辑卡片</button></footer></aside></div>;
|
||
}
|
||
|
||
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 <div className={styles.modalBackdrop} onMouseDown={onClose}><form className={styles.modal} onMouseDown={(event) => event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (title.trim()) onCreate(title.trim(), laneId, priority); }}><header><div><h2>新建看板卡片</h2><p>创建后可直接拖动到其他泳道。</p></div><button type="button" onClick={onClose}>×</button></header><label><span>卡片标题</span><input autoFocus value={title} onChange={(event) => setTitle(event.target.value)} placeholder="请输入任务名称" required /></label><label><span>初始泳道</span><select value={laneId} onChange={(event) => setLaneId(event.target.value)}>{lanes.map((lane) => <option value={lane.id} key={lane.id}>{lane.title}</option>)}</select></label><fieldset><legend>优先级</legend>{["高", "中", "低"].map((value) => <label key={value}><input type="radio" checked={priority === value} onChange={() => setPriority(value)} />{value}</label>)}</fieldset><footer><button type="button" onClick={onClose}>取消</button><button type="submit">创建卡片</button></footer></form></div>;
|
||
}
|
||
|
||
function LaneModal({ onClose, onCreate }: { onClose: () => void; onCreate: (title: string, limit: number) => void }) {
|
||
const [title, setTitle] = useState(""); const [limit, setLimit] = useState(5);
|
||
return <div className={styles.modalBackdrop} onMouseDown={onClose}><form className={styles.modal} onMouseDown={(event) => event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (title.trim()) onCreate(title.trim(), limit); }}><header><div><h2>新建泳道</h2><p>设置泳道名称和在制品上限。</p></div><button type="button" onClick={onClose}>×</button></header><label><span>泳道名称</span><input autoFocus value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例如:等待发布" required /></label><label><span>卡片上限</span><input type="number" min="1" max="99" value={limit} onChange={(event) => setLimit(Number(event.target.value))} /></label><footer><button type="button" onClick={onClose}>取消</button><button type="submit">创建泳道</button></footer></form></div>;
|
||
}
|
||
|
||
function ActivityDrawer({ activities, onClose }: { activities: Activity[]; onClose: () => void }) {
|
||
return <div className={styles.drawerBackdrop} onMouseDown={onClose}><aside className={`${styles.drawer} ${styles.activityDrawer}`} onMouseDown={(event) => event.stopPropagation()}><header><div><small>BOARD ACTIVITY</small><h2>看板动态</h2></div><button onClick={onClose}>×</button></header><div className={styles.drawerBody}>{activities.map((item) => <div className={styles.moveActivity} key={item.id}><span>↻</span><p><strong>{item.card}</strong> 从“{item.from}”移动到“{item.to}”<small>{item.time} · 林知夏</small></p></div>)}</div></aside></div>;
|
||
}
|