This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/kanban/page.tsx
2026-08-07 14:49:50 +08:00

199 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>;
}