import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; import { AppShell } from "../components/AppShell"; import { TaskDetail } from "../components/TaskDetail"; import { Check, Inbox, Plus, Search } from "../components/icons"; import { Button, EmptyState, InlineAlert, PageHeader, PriorityDot, SkeletonRows, StatusPill, } from "../components/ui"; import { ApiError, api } from "../lib/client"; import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse"; import type { Item, ItemStatus, Page } from "../lib/types"; const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [ { key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" }, { key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" }, { key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" }, { key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" }, { key: "all", label: "全部", hint: "含已取消", createAs: "todo" }, ]; function dueLabel(iso: string | null): { text: string; overdue: boolean } | null { if (!iso) return null; const due = new Date(iso); const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); return { text, overdue: due.getTime() < Date.now() }; } export function TasksPage() { const { wsId = "" } = useParams<{ wsId: string }>(); const [view, setView] = useState("today"); const [items, setItems] = useState([]); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(false); const [query, setQuery] = useState(""); const [draft, setDraft] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // 从通知点进来时带 ?open=,落地即展开那一条 const [searchParams, setSearchParams] = useSearchParams(); const [openId, setOpenId] = useState(searchParams.get("open")); const [activeIdx, setActiveIdx] = useState(0); const reqId = useRef(0); const current = VIEWS.find((v) => v.key === view); const load = useCallback( async (nextCursor?: string | null) => { if (!wsId) return; // 换视图/改搜索词会连发多个请求,慢的那个后到会把新结果覆盖掉。 // 每次请求记一个号,回来时不是最新号就整包丢弃。 const mine = ++reqId.current; setLoading(true); try { const params = new URLSearchParams({ view, limit: "30" }); if (query.trim()) params.set("q", query.trim()); if (nextCursor) params.set("cursor", nextCursor); const page = await api.get>(`/workspaces/${wsId}/items?${params}`); if (mine !== reqId.current) return; setItems((prev) => (nextCursor ? [...prev, ...page.items] : page.items)); setCursor(page.next_cursor); setHasMore(page.has_more); setError(null); } catch (err) { if (mine !== reqId.current) return; setError(err instanceof ApiError ? err.message : "加载失败"); } finally { if (mine === reqId.current) setLoading(false); } }, [wsId, view, query], ); useEffect(() => { void load(null); }, [load]); const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]); useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable; if (e.key === "Escape") { setOpenId(null); (target as HTMLInputElement).blur?.(); return; } if (typing || items.length === 0) return; if (e.key === "j" || e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, items.length - 1)); } else if (e.key === "k" || e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); } else if (e.key === "e") { if (activeItem) setOpenId(activeItem.id); } else if (e.key === "Enter") { if (activeItem) void changeStatus(activeItem, activeItem.status === "done" ? "todo" : "done"); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }); async function addTask() { const raw = draft.trim(); if (!raw || !wsId || !current) return; const parsed = parseQuickAdd(raw, current.createAs); try { const created = await api.post(`/workspaces/${wsId}/items`, { title: parsed.title, status: parsed.status, priority: parsed.priority, tag_names: parsed.tagNames, }); setDraft(""); if (view !== "done") setItems((prev) => [created, ...prev]); } catch (err) { setError(err instanceof ApiError ? err.message : "创建失败"); } } async function changeStatus(item: Item, status: ItemStatus) { if (!wsId) return; try { const updated = await api.post(`/workspaces/${wsId}/items/${item.id}/status`, { status, version: item.version, }); setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it))); } catch (err) { if (err instanceof ApiError && err.code === 4009) { setError("这条任务刚被别人改过,已为你刷新"); void load(null); return; } setError(err instanceof ApiError ? err.message : "状态更新失败"); } } return ( 按 j / k 移动,Enter 完成,e 打开详情} actions={
setQuery(e.target.value)} />
} />
{VIEWS.map((v) => { const active = view === v.key; return ( ); })}
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void addTask(); }} />
{error && (
{error}
)}
{loading && items.length === 0 ? ( ) : items.length === 0 ? ( } title={view === "today" ? "今天没有到期的事,挺好" : "这里还空着"} hint={ view === "today" ? "想提前安排?切到「进行中」,或直接在上面加一件。" : "在上面输入一件要做的事,回车就进列表。" } /> ) : ( items.map((item, index) => { const due = dueLabel(item.due_at); const done = item.status === "done"; // 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。 const canComplete = item.status !== "inbox" && item.status !== "canceled"; return (
setActiveIdx(index)} > {canComplete ? ( ) : ( )} {/* 宽屏把元信息推到右侧,一行读完;窄屏仍折到标题下面 */}
{item.priority > 0 && ( 优先级 {PRIORITY_LABEL[item.priority]} )} {due && ( 截止 {due.text} {due.overdue && !done ? "(逾期)" : ""} )} {item.tags.map((tag) => ( #{tag.name} ))}
{item.status === "inbox" && ( )} {(item.status === "todo" || item.status === "blocked") && ( )} {item.status === "doing" && ( )}
); }) )}
{hasMore && (
)} {openId && wsId && ( { setOpenId(null); if (searchParams.get("open")) setSearchParams({}, { replace: true }); }} onChanged={(updated) => setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it))) } onDeleted={(id) => setItems((prev) => prev.filter((it) => it.id !== id))} /> )}
); }