import { useCallback, useEffect, useRef, useState } from "react"; import { api, shortId, uploadAttachments } from "../api/client"; import { paths } from "../api/paths"; import type { DispatchBody, Task } from "../api/types"; export const TASK_PAGE_SIZE = 20; // Task list, server-side paginated. The old hook fetched every task // in the project and filtered client-side; at scale that made every // 10 s refresh a full-table fetch and a full render. Now each fetch // asks for one page (newest first) with the current filters, and the // server returns total + status_counts for that filter set. tasks // here is ONLY the current page; taskTotal / status counts are the // project-wide / filtered totals from the server, never page-only // numbers (DataCards must not present a page as the whole project). // Page state lives in a ref as well as state, because refreshTasks // is called from auto-refresh and from other hooks that must not // reset the page, while filter/project changes reset it to page 1. export function useTaskList() { const [tasks, setTasks] = useState([]); const [taskError, setTaskError] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [executorFilter, setExecutorFilter] = useState(""); const [searchText, setSearchText] = useState(""); const [page, setPageState] = useState(0); const [taskTotal, setTaskTotal] = useState(0); const [taskStatusCounts, setTaskStatusCounts] = useState>({}); const pageRef = useRef(0); const filterRef = useRef({ status: "", executor: "", q: "" }); filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText }; const lastProjectRef = useRef(""); const refreshTasks = useCallback(async (pid?: string, currentPid?: string, pageOverride?: number) => { const id = pid !== undefined ? pid : currentPid; if (!id) { setTasks([]); setTaskTotal(0); setTaskStatusCounts({}); return; } lastProjectRef.current = id; const p = pageOverride !== undefined ? pageOverride : pageRef.current; const f = filterRef.current; try { const d = await api<{ tasks: Task[]; total?: number; status_counts?: Record }>( paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q)); setTasks(d.tasks || []); setTaskTotal(d.total ?? (d.tasks || []).length); setTaskStatusCounts(d.status_counts || {}); setTaskError(""); } catch (e: any) { setTaskError("加载失败:" + e.message); } }, []); const setPage = useCallback((p: number) => { const next = Math.max(0, p); pageRef.current = next; setPageState(next); }, []); const goToPage = useCallback((p: number) => { setPage(p); void refreshTasks(lastProjectRef.current || undefined, undefined, p); }, [refreshTasks, setPage]); // Filter changes refetch page 1 (debounced, so typing a search // query does not fire one request per keystroke). Project changes // do not fire this effect (filters are unchanged), so the store's // explicit refreshTasksFirstPage remains the only fetch there. useEffect(() => { if (!lastProjectRef.current) return; const t = setTimeout(() => { void refreshTasks(lastProjectRef.current, undefined, 0); }, 300); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [statusFilter, executorFilter, searchText]); // Filter/project changes reset to page 1: the old page may not // exist under the new filter, and staying on it would show an // empty page with a misleading "共 N 个" count. const setStatusFilterPage = useCallback((v: string) => { setStatusFilter(v); setPage(0); }, [setPage]); const setExecutorFilterPage = useCallback((v: string) => { setExecutorFilter(v); setPage(0); }, [setPage]); const setSearchTextPage = useCallback((v: string) => { setSearchText(v); setPage(0); }, [setPage]); const refreshTasksFirstPage = useCallback(async (pid?: string) => { setPage(0); await refreshTasks(pid, undefined, 0); }, [refreshTasks, setPage]); const totalPages = Math.max(1, Math.ceil(taskTotal / TASK_PAGE_SIZE)); const taskCountText = taskTotal === 0 ? "共 0 个任务" : "共 " + taskTotal + " 个任务,第 " + (page + 1) + " / " + totalPages + " 页"; // Back-compat for the list section: it renders the current page // as-is (server already ordered newest first). const filteredTasks = tasks; const dispatch = useCallback(async (body: DispatchBody, atts: Array<{ path: string; file: File }>): Promise => { const payload: DispatchBody = { ...body }; if (atts.length) { payload.upload_id = await uploadAttachments(atts); } const t = await api(paths.tasksCreate, "POST", payload); return "任务已建:" + shortId(t.id); }, []); return { tasks, taskError, statusFilter, setStatusFilter: setStatusFilterPage, executorFilter, setExecutorFilter: setExecutorFilterPage, searchText, setSearchText: setSearchTextPage, filteredTasks, taskCountText, refreshTasks, refreshTasksFirstPage, page, totalPages, taskTotal, taskStatusCounts, goToPage, dispatch, }; }