任务列表分页(服务端 + 前端): - GET /tasks 新增 limit(1..100,非法回 400)、offset、 order=desc、status/executor/q 服务端过滤;回 total(过滤后 计数)与 status_counts(全项目统计,不受过滤与分页影响, 数据卡片不再把当前页当成全项目);无 limit 时旧行为不变 (全量、正序,协调子任务与旧调用不受影响)。 - 前端每页 20 条、最新在前,上一页/下一页 + 页码;筛选与 搜索改服务端过滤(搜索防抖 300ms),切筛选/切项目/派发 后回第 1 页,10 秒刷新与刷新按钮停在当前页;新测试 test_task_list_pagination.py 5 项。 分页面签: - 顶栏锚点导航改成 5 个页签:工作台(派发 + 任务列表 + 对话 + 详情)、项目(项目 + 成员)、环境与 Workers、 技能与自动化(技能 + 自动化 + 分诊)、管理与 Key; 数据卡片仍在页签上方常驻。选中任务存在 App 层 store 里,切页不丢,回来仍是同一任务、10 秒刷新续同步; 页签同步到 location.hash(#tab-workbench 等),刷新与 分享链接落在同一页签;窄屏工作台仍保持派发在前的顺序。 - 工作台页签仍是左(任务)右(派发/对话/详情)两栏, 沿用等高内部滚动规则;其它页签单列堆叠。
107 lines
5.1 KiB
TypeScript
107 lines
5.1 KiB
TypeScript
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<Task[]>([]);
|
|
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<Record<string, number>>({});
|
|
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<string, number> }>(
|
|
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<string> => {
|
|
const payload: DispatchBody = { ...body };
|
|
if (atts.length) {
|
|
payload.upload_id = await uploadAttachments(atts);
|
|
}
|
|
const t = await api<Task>(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,
|
|
};
|
|
}
|