import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { AppShell } from "../components/AppShell"; import { TaskDetail } from "../components/TaskDetail"; import { Check, Columns } from "../components/icons"; import { Button, EmptyState, InlineAlert, PageHeader, StatusPill } from "../components/ui"; import { ApiError, api } from "../lib/client"; import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse"; import type { Item, ItemStatus } from "../lib/types"; const COLUMNS: { key: ItemStatus; label: string; dot: string; tint: string }[] = [ { key: "todo", label: "待办", dot: "bg-sky-500", tint: "bg-sky-50/40" }, { key: "doing", label: "进行中", dot: "bg-primary-500", tint: "bg-primary-50/40" }, { key: "blocked", label: "被阻塞", dot: "bg-wheat-500", tint: "bg-wheat-50/40" }, { key: "done", label: "已完成", dot: "bg-sage-500", tint: "bg-sage-50/40" }, ]; interface BoardPayload { columns: Record; order: string[]; } interface BatchResult { updated: { id: string; version: number; unchanged: boolean }[]; failed: { id: string; code: number; message: string; allowed?: string[] }[]; } export function BoardPage() { const { wsId = "" } = useParams<{ wsId: string }>(); const [columns, setColumns] = useState>({}); const [selected, setSelected] = useState>(new Set()); const [dragId, setDragId] = useState(null); const [hoverCol, setHoverCol] = useState(null); const [openId, setOpenId] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const load = useCallback(async () => { if (!wsId) return; try { const data = await api.get(`/workspaces/${wsId}/items/board`); setColumns(data.columns); setSelected((prev) => { const alive = new Set(Object.values(data.columns).flat().map((i) => i.id)); return new Set([...prev].filter((id) => alive.has(id))); }); setError(null); } catch (err) { setError(err instanceof ApiError ? err.message : "看板加载失败"); } finally { setLoading(false); } }, [wsId]); useEffect(() => { void load(); }, [load]); const allItems = useMemo(() => Object.values(columns).flat(), [columns]); const selectedItems = allItems.filter((i) => selected.has(i.id)); function moveLocally(item: Item, to: ItemStatus) { setColumns((prev) => { const next: Record = {}; for (const [key, list] of Object.entries(prev)) { next[key] = list.filter((i) => i.id !== item.id); } next[to] = [{ ...item, status: to }, ...(next[to] ?? [])]; return next; }); } async function move(item: Item, to: ItemStatus) { if (!wsId || item.status === to) return; if (!canTransition(item.status, to)) { setError(`「${STATUS_LABEL[item.status]}」不能直接变成「${STATUS_LABEL[to]}」`); return; } moveLocally(item, to); try { const updated = await api.post(`/workspaces/${wsId}/items/${item.id}/status`, { status: to, version: item.version, }); setColumns((prev) => { const next: Record = {}; for (const [key, list] of Object.entries(prev)) { next[key] = list.map((i) => (i.id === updated.id ? updated : i)); } return next; }); setError(null); } catch (err) { void load(); if (err instanceof ApiError && err.code === 4009) { setError("这条刚被别人改过,看板已刷新,请再拖一次"); } else { setError(err instanceof ApiError ? err.message : "移动失败"); } } } async function batchMove(to: ItemStatus) { if (!wsId || selectedItems.length === 0) return; const movable = selectedItems.filter((i) => canTransition(i.status, to)); const blocked = selectedItems.length - movable.length; if (movable.length === 0) { setError(`选中的 ${selectedItems.length} 条都不能直接变成「${STATUS_LABEL[to]}」`); return; } try { const result = await api.post(`/workspaces/${wsId}/items/batch-status`, { status: to, items: movable.map((i) => ({ id: i.id, version: i.version })), }); const failedIds = new Set(result.failed.map((f) => f.id)); const note = [ `已移动 ${result.updated.length} 条`, failedIds.size > 0 ? `${failedIds.size} 条因并发冲突未移动` : "", blocked > 0 ? `${blocked} 条状态不允许` : "", ] .filter(Boolean) .join(","); setError(note); setSelected(new Set()); await load(); } catch (err) { setError(err instanceof ApiError ? err.message : "批量移动失败"); } } async function onDrop(to: ItemStatus) { setHoverCol(null); const item = allItems.find((i) => i.id === dragId); setDragId(null); if (item) await move(item, to); } return ( 收件箱不上看板,先归类再排期} /> {selectedItems.length > 0 && (
已选 {selectedItems.length} 条 {COLUMNS.map((col) => ( ))}
)} {error && (
{error}
)} {loading && Object.keys(columns).length === 0 ? (
{COLUMNS.map((c) => (
))}
) : allItems.length === 0 ? (
} title="看板还是空的" hint="任务页收下几条待办,这里就会按状态分列排开。" />
) : (
{COLUMNS.map((col) => { const list = columns[col.key] ?? []; const hot = hoverCol === col.key; return (
{ e.preventDefault(); setHoverCol(col.key); }} onDragLeave={() => setHoverCol((prev) => (prev === col.key ? null : prev))} onDrop={() => void onDrop(col.key)} className={`rounded-2xl border p-2 transition-all ${ hot ? "border-primary-400 bg-primary-50/60 ring-2 ring-primary-400/25" : "border-line-200 bg-paper-50/70" }`} >
{col.label} {list.length}
{list.map((item) => (
setDragId(item.id)} onDragEnd={() => setDragId(null)} className={`group cursor-grab rounded-xl border border-line-200 bg-white p-2.5 shadow-card transition hover:-translate-y-px hover:border-line-300 active:cursor-grabbing ${ dragId === item.id ? "rotate-1 opacity-50" : "" }`} >
{col.key === "done" ? ( ) : ( { setSelected((prev) => { const next = new Set(prev); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next; }); }} aria-label={`选择 ${item.title}`} /> )}
{item.priority > 0 && ( {PRIORITY_LABEL[item.priority]} )} {item.due_at && ( {new Date(item.due_at).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric", })} )} {item.tags.map((t) => ( #{t.name} ))}
))} {list.length === 0 && (

拖到这里

)}
); })}
)} {openId && wsId && ( setOpenId(null)} onChanged={() => void load()} onDeleted={() => void load()} /> )} ); }