console: 任务列表服务端分页 + 控制台分页面签
任务列表分页(服务端 + 前端): - 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 等),刷新与 分享链接落在同一页签;窄屏工作台仍保持派发在前的顺序。 - 工作台页签仍是左(任务)右(派发/对话/详情)两栏, 沿用等高内部滚动规则;其它页签单列堆叠。
This commit is contained in:
1 parent
9a11ef27b8
commit
e5b6c45c83
11 files changed
+443
-122
No files matched your search
+108
-74
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConsoleContext, useConsole } from "./context";
|
||||
import { useConsoleStore } from "./hooks/useConsoleStore";
|
||||
import { useAutoRefresh, useIntervalMs } from "./hooks/useAutoRefresh";
|
||||
@@ -19,30 +20,36 @@ import SkillsToolsPanel from "./sections/SkillsToolsPanel";
|
||||
import AutomationsSection from "./sections/AutomationsSection";
|
||||
import TriageSection from "./sections/TriageSection";
|
||||
|
||||
// Layout only: ONE scrollable console page in GitHub Primer style —
|
||||
// a dark header bar (name + real connection status + health + quota
|
||||
// + refresh stamp + anchor nav as header links), a data-cards row,
|
||||
// then every module as a bordered section. At medium/wide widths the
|
||||
// sections live in three independent column wrappers (left: tasks +
|
||||
// projects; middle: dispatch + conversation; right: environments +
|
||||
// Workers + skills + automations + triage + admin + API key) so each
|
||||
// column's modules stack tightly on their own, instead of sharing
|
||||
// grid rows with the other columns — shared rows were the blank-gap
|
||||
// bug (a tall task list in row 2 held the whole row tall, so the
|
||||
// conversation in row 3 started far below a short dispatch box).
|
||||
// At narrow width the wrappers use display:contents plus an explicit
|
||||
// order per section, so the single column stacks in the original
|
||||
// DOM order (dispatch, tasks, conversation, projects, …), not in
|
||||
// column-group order. Detail/context panels sit under their own
|
||||
// sections below (detail under 对话, members under 项目, and so on).
|
||||
const NAV: Array<[string, string]> = [
|
||||
["#sec-dispatch", "派发"], ["#sec-tasks", "任务"], ["#sec-conv", "对话"],
|
||||
["#sec-projects", "项目"], ["#sec-env", "环境"], ["#sec-workers", "Workers"],
|
||||
["#sec-skills", "技能"], ["#sec-automations", "自动化"], ["#sec-triage", "分诊"],
|
||||
["#sec-admin", "管理区"], ["#sec-key", "API Key"],
|
||||
// Layout: GitHub Primer style, data cards on top, then TAB pages
|
||||
// instead of one endless page. The old layout stacked 11 sections
|
||||
// (dispatch, tasks, conversation, projects, env, workers, skills,
|
||||
// automations, triage, admin, API Key, plus detail panels) on one
|
||||
// page; tabbing is what actually cuts the visible length, while
|
||||
// list pagination (see useTaskList) cuts the data fetched per page.
|
||||
// Tabs:
|
||||
// 工作台:派发 + 任务列表(分页)+ 对话 + 任务详情
|
||||
// 项目:项目 + 成员
|
||||
// 环境与 Workers:环境 + 环境上下文 + Workers + 健康
|
||||
// 技能与自动化:技能 + 技能工具 + 自动化 + 分诊
|
||||
// 管理与 Key:管理区 + API Key
|
||||
// Selected task survives tab switches: the store (useConsoleStore)
|
||||
// lives in App, above the tabs, so currentTask / detail / chat
|
||||
// state is not unmounted with a tab — switching back shows the same
|
||||
// task, and the 10 s refresh re-syncs its detail lists. The tab is
|
||||
// synced to location.hash (#tab-workbench etc.) so a reload or a
|
||||
// shared link lands on the same tab; unknown hashes fall back to
|
||||
// the workbench.
|
||||
type TabId = "workbench" | "projects" | "env" | "skills" | "admin";
|
||||
const TABS: Array<[TabId, string]> = [
|
||||
["workbench", "工作台"], ["projects", "项目"], ["env", "环境与 Workers"],
|
||||
["skills", "技能与自动化"], ["admin", "管理与 Key"],
|
||||
];
|
||||
function tabFromHash(): TabId {
|
||||
const h = window.location.hash.replace("#tab-", "");
|
||||
return (TABS.some(([id]) => id === h) ? h : "workbench") as TabId;
|
||||
}
|
||||
|
||||
function TopBar() {
|
||||
function TopBar({ tab, setTab }: { tab: TabId; setTab: (t: TabId) => void }) {
|
||||
return (
|
||||
<header className="dash-topbar">
|
||||
<div className="dash-topbar-row">
|
||||
@@ -52,8 +59,10 @@ function TopBar() {
|
||||
<span className="muted dash-hide-sm"><TopQuota /></span>
|
||||
<span className="muted dash-stamp"><TopStamp /></span>
|
||||
</div>
|
||||
<nav className="dash-nav" aria-label="页面导航">
|
||||
{NAV.map(([href, label]) => <a key={href} href={href}>{label}</a>)}
|
||||
<nav className="dash-nav tab-nav" aria-label="页面签">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" className={tab === id ? "active" : ""} onClick={() => setTab(id)}>{label}</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
@@ -78,63 +87,88 @@ export default function App() {
|
||||
const store = useConsoleStore();
|
||||
useAutoRefresh((selectFocused) => store.refreshAll(selectFocused), 10000);
|
||||
useIntervalMs(() => store.refreshSlow(), 60000);
|
||||
const [tab, setTabState] = useState<TabId>(() => tabFromHash());
|
||||
const setTab = (t: TabId) => {
|
||||
setTabState(t);
|
||||
try { window.history.replaceState(null, "", "#tab-" + t); } catch { /* noop */ }
|
||||
};
|
||||
useEffect(() => {
|
||||
const onHash = () => setTabState(tabFromHash());
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
const scrollToDispatch = () => {
|
||||
document.getElementById("sec-dispatch")?.scrollIntoView({ behavior: "smooth" });
|
||||
setTab("workbench");
|
||||
setTimeout(() => document.getElementById("sec-dispatch")?.scrollIntoView({ behavior: "smooth" }), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConsoleContext.Provider value={store}>
|
||||
<TopBar />
|
||||
<main className="dash-page">
|
||||
<TopBar tab={tab} setTab={setTab} />
|
||||
<main className="dash-page dash-page-tabs">
|
||||
<DataCards />
|
||||
<div className="dash-col dash-col-left">
|
||||
<section id="sec-tasks" className="dash-section card">
|
||||
<h2>任务列表</h2>
|
||||
<TaskListSection onNew={scrollToDispatch} />
|
||||
</section>
|
||||
<section id="sec-projects" className="dash-section">
|
||||
<ProjectsSection />
|
||||
<MembersPanel />
|
||||
</section>
|
||||
</div>
|
||||
<div className="dash-col dash-col-mid">
|
||||
<section id="sec-dispatch" className="dash-section">
|
||||
<DispatchSection />
|
||||
</section>
|
||||
<section id="sec-conv" className="dash-section">
|
||||
<div className="card">
|
||||
<h2>对话</h2>
|
||||
<ConversationSection />
|
||||
{tab === "workbench" ? (
|
||||
<div className="tab-content tab-workbench">
|
||||
<div className="dash-col dash-col-left">
|
||||
<section id="sec-tasks" className="dash-section card">
|
||||
<h2>任务列表</h2>
|
||||
<TaskListSection onNew={scrollToDispatch} />
|
||||
</section>
|
||||
</div>
|
||||
<TaskDetailPanel />
|
||||
</section>
|
||||
</div>
|
||||
<div className="dash-col dash-col-right">
|
||||
<section id="sec-env" className="dash-section">
|
||||
<EnvSection />
|
||||
<EnvContextPanel />
|
||||
</section>
|
||||
<section id="sec-workers" className="dash-section">
|
||||
<WorkersSection />
|
||||
<HealthPanel />
|
||||
</section>
|
||||
<section id="sec-skills" className="dash-section">
|
||||
<SkillsSection />
|
||||
<SkillsToolsPanel />
|
||||
</section>
|
||||
<section id="sec-automations" className="dash-section">
|
||||
<AutomationsSection />
|
||||
</section>
|
||||
<section id="sec-triage" className="dash-section">
|
||||
<TriageSection />
|
||||
</section>
|
||||
<section id="sec-admin" className="dash-section">
|
||||
<AdminSection />
|
||||
</section>
|
||||
<section id="sec-key" className="dash-section">
|
||||
<ApiKeySection />
|
||||
</section>
|
||||
</div>
|
||||
<div className="dash-col dash-col-mid">
|
||||
<section id="sec-dispatch" className="dash-section">
|
||||
<DispatchSection />
|
||||
</section>
|
||||
<section id="sec-conv" className="dash-section">
|
||||
<div className="card">
|
||||
<h2>对话</h2>
|
||||
<ConversationSection />
|
||||
</div>
|
||||
<TaskDetailPanel />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
) : tab === "projects" ? (
|
||||
<div className="tab-content">
|
||||
<section id="sec-projects" className="dash-section">
|
||||
<ProjectsSection />
|
||||
<MembersPanel />
|
||||
</section>
|
||||
</div>
|
||||
) : tab === "env" ? (
|
||||
<div className="tab-content">
|
||||
<section id="sec-env" className="dash-section">
|
||||
<EnvSection />
|
||||
<EnvContextPanel />
|
||||
</section>
|
||||
<section id="sec-workers" className="dash-section">
|
||||
<WorkersSection />
|
||||
<HealthPanel />
|
||||
</section>
|
||||
</div>
|
||||
) : tab === "skills" ? (
|
||||
<div className="tab-content">
|
||||
<section id="sec-skills" className="dash-section">
|
||||
<SkillsSection />
|
||||
<SkillsToolsPanel />
|
||||
</section>
|
||||
<section id="sec-automations" className="dash-section">
|
||||
<AutomationsSection />
|
||||
</section>
|
||||
<section id="sec-triage" className="dash-section">
|
||||
<TriageSection />
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tab-content">
|
||||
<section id="sec-admin" className="dash-section">
|
||||
<AdminSection />
|
||||
</section>
|
||||
<section id="sec-key" className="dash-section">
|
||||
<ApiKeySection />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</ConsoleContext.Provider>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,13 @@ export const paths = {
|
||||
environmentsCreate: "/environments",
|
||||
environmentArchive: (id: string) => "/environments/" + enc(id) + "/archive",
|
||||
tasks: (pid: string) => "/tasks?project_id=" + enc(pid),
|
||||
tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string) => {
|
||||
let u = "/tasks?project_id=" + enc(pid) + "&limit=" + limit + "&offset=" + offset + "&order=desc";
|
||||
if (status) u += "&status=" + enc(status);
|
||||
if (executor) u += "&executor=" + enc(executor);
|
||||
if (q && q.trim()) u += "&q=" + enc(q.trim());
|
||||
return u;
|
||||
},
|
||||
tasksByParent: (pid: string, parent: string) => "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent),
|
||||
tasksCreate: "/tasks",
|
||||
task: (id: string) => "/tasks/" + enc(id),
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ConsoleCtx {
|
||||
tasks: Task[]; taskCountText: string; taskError: string; refreshTasks: () => void;
|
||||
statusFilter: string; setStatusFilter: (v: string) => void; executorFilter: string; setExecutorFilter: (v: string) => void; searchText: string; setSearchText: (v: string) => void;
|
||||
filteredTasks: Task[];
|
||||
taskPage: number; taskTotalPages: number; taskTotal: number; taskStatusCounts: Record<string, number>; goToTaskPage: (p: number) => void;
|
||||
currentTask: Task | null; detailBody: string; actionMsg: string; actionReasons: string;
|
||||
showDetail: (id: string) => void; doAction: (kind: string) => void; taskCost: () => void; costBox: string; costSummary: TaskCost | null | undefined;
|
||||
forkVisible: boolean; setForkVisible: (v: boolean) => void; forkTask: (msg: string) => Promise<void>; forkMsg: string;
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useConsoleStore(): ConsoleCtx {
|
||||
const selectProject = useCallback((id: string) => {
|
||||
catalog.setProjectId(id);
|
||||
projectIdRef.current = id;
|
||||
void taskList.refreshTasks(id);
|
||||
void taskList.refreshTasksFirstPage(id);
|
||||
void catalog.refreshEnvs(id);
|
||||
void catalog.refreshMembers(id);
|
||||
void catalog.refreshSkills(id);
|
||||
@@ -128,6 +128,8 @@ export function useConsoleStore(): ConsoleCtx {
|
||||
executorFilter: taskList.executorFilter, setExecutorFilter: taskList.setExecutorFilter,
|
||||
searchText: taskList.searchText, setSearchText: taskList.setSearchText,
|
||||
filteredTasks: taskList.filteredTasks,
|
||||
taskPage: taskList.page, taskTotalPages: taskList.totalPages, taskTotal: taskList.taskTotal,
|
||||
taskStatusCounts: taskList.taskStatusCounts, goToTaskPage: taskList.goToPage,
|
||||
currentTask: detail.currentTask, detailBody: detail.detailBody, actionMsg: detail.actionMsg, actionReasons: detail.actionReasons,
|
||||
showDetail: (id: string) => void detail.showDetail(id),
|
||||
doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox, costSummary: detail.costSummary,
|
||||
@@ -147,7 +149,7 @@ export function useConsoleStore(): ConsoleCtx {
|
||||
threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction,
|
||||
dispatch: async (body, atts) => {
|
||||
const msg = await taskList.dispatch(body, atts);
|
||||
void taskList.refreshTasks(projectIdRef.current);
|
||||
void taskList.refreshTasksFirstPage(projectIdRef.current);
|
||||
return msg;
|
||||
},
|
||||
dispatchMsg: "",
|
||||
|
||||
@@ -1,36 +1,91 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
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) => {
|
||||
const refreshTasks = useCallback(async (pid?: string, currentPid?: string, pageOverride?: number) => {
|
||||
const id = pid !== undefined ? pid : currentPid;
|
||||
if (!id) { setTasks([]); return; }
|
||||
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[] }>(paths.tasks(id));
|
||||
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 filteredTasks = useMemo(() => {
|
||||
const q = searchText.trim().toLowerCase();
|
||||
return tasks.filter((t) => {
|
||||
if (statusFilter && t.status !== statusFilter) return false;
|
||||
if (executorFilter && (t.executor || "") !== executorFilter) return false;
|
||||
if (q && !((t.id || "") + " " + (t.prompt || "")).toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [tasks, statusFilter, executorFilter, searchText]);
|
||||
const setPage = useCallback((p: number) => {
|
||||
const next = Math.max(0, p);
|
||||
pageRef.current = next;
|
||||
setPageState(next);
|
||||
}, []);
|
||||
|
||||
const taskCountText = filteredTasks.length + " 个任务(共 " + tasks.length + " 个)";
|
||||
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 };
|
||||
@@ -41,5 +96,11 @@ export function useTaskList() {
|
||||
return "任务已建:" + shortId(t.id);
|
||||
}, []);
|
||||
|
||||
return { tasks, taskError, statusFilter, setStatusFilter, executorFilter, setExecutorFilter, searchText, setSearchText, filteredTasks, taskCountText, refreshTasks, dispatch };
|
||||
return {
|
||||
tasks, taskError, statusFilter, setStatusFilter: setStatusFilterPage,
|
||||
executorFilter, setExecutorFilter: setExecutorFilterPage,
|
||||
searchText, setSearchText: setSearchTextPage,
|
||||
filteredTasks, taskCountText, refreshTasks, refreshTasksFirstPage,
|
||||
page, totalPages, taskTotal, taskStatusCounts, goToPage, dispatch,
|
||||
};
|
||||
}
|
||||
@@ -30,16 +30,18 @@ export default function DataCards() {
|
||||
: ctx.quotaText.includes("未填 key") || ctx.quotaText === "未加载" ? "未加载"
|
||||
: quotaMatch ? "$" + quotaMatch[1] : "配额";
|
||||
|
||||
// Status counts come from the server (all project rows), not from
|
||||
// ctx.tasks, which is now only the current page — counting the page
|
||||
// would present 20 tasks as the whole project.
|
||||
const statusCounts = (["running", "completed", "failed", "queued", "merged", "interrupted"] as const)
|
||||
.map((s) => ctx.tasks.filter((t) => t.status === s).length)
|
||||
.map((n, i) => (["running", "completed", "failed", "queued", "merged", "interrupted"] as const)[i] + " " + n)
|
||||
.map((s) => s + " " + (ctx.taskStatusCounts[s] || 0))
|
||||
.join(" | ");
|
||||
const selectedProject = ctx.projects.find((p) => p.id === ctx.projectId);
|
||||
const tasksCard = ctx.taskError
|
||||
? { value: "加载失败", sub: ctx.taskError }
|
||||
: !ctx.projectId
|
||||
? { value: "先选项目", sub: ctx.projectMsg || "项目加载后自动选第一个" }
|
||||
: { value: String(ctx.tasks.length), sub: ctx.taskCountText + " | " + statusCounts + (selectedProject ? " | " + selectedProject.name : "") };
|
||||
: { value: String(ctx.taskTotal), sub: ctx.taskCountText + " | " + statusCounts + (selectedProject ? " | " + selectedProject.name : "") };
|
||||
|
||||
let workersCard: { value: string; sub: string };
|
||||
if (ctx.workersError) {
|
||||
|
||||
@@ -45,12 +45,17 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
|
||||
<button onClick={ctx.refreshTasks}>刷新</button>
|
||||
<span className="muted">{ctx.taskCountText}</span>
|
||||
</div>
|
||||
<div className="bar">
|
||||
<button disabled={ctx.taskPage <= 0} onClick={() => ctx.goToTaskPage(ctx.taskPage - 1)}>上一页</button>
|
||||
<span className="muted">第 {ctx.taskPage + 1} / {ctx.taskTotalPages} 页(每页 20 条,最新在前)</span>
|
||||
<button disabled={ctx.taskPage + 1 >= ctx.taskTotalPages} onClick={() => ctx.goToTaskPage(ctx.taskPage + 1)}>下一页</button>
|
||||
</div>
|
||||
{ctx.taskError ? <div className="err">{ctx.taskError}</div>
|
||||
: !ctx.projectId ? <div className="muted">先选项目</div>
|
||||
: ctx.filteredTasks.length === 0 ? <div className="muted">{ctx.tasks.length ? "没有符合筛选条件的任务" : "该项目暂无任务"}</div>
|
||||
: ctx.filteredTasks.length === 0 ? <div className="muted">{ctx.taskTotal ? "没有符合筛选条件的任务" : "该项目暂无任务"}</div>
|
||||
: (
|
||||
<ul className="issue-list">
|
||||
{ctx.filteredTasks.slice().reverse().map((t) => {
|
||||
{ctx.filteredTasks.map((t) => {
|
||||
const [dur, durTitle] = durationOf(t);
|
||||
const [tok, tokTitle] = tokenOf(t);
|
||||
const fc = firstChar(t);
|
||||
|
||||
@@ -56,6 +56,28 @@ button.danger:hover{background:var(--danger-bg);color:var(--danger)}
|
||||
.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}
|
||||
.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}
|
||||
.dash-nav a:hover{background:rgba(255,255,255,.12);text-decoration:none}
|
||||
/* Tab nav: buttons styled like the old anchor nav (dark header, white
|
||||
text), active tab gets the hover background permanently. */
|
||||
.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}
|
||||
.tab-nav button:hover{background:rgba(255,255,255,.12)}
|
||||
.tab-nav button.active{background:rgba(255,255,255,.18)}
|
||||
/* Tab pages: the main page is a flex column (not the old 3-column
|
||||
grid), so a tab's content spans the full width. The workbench tab
|
||||
is a 2-column grid (tasks left, dispatch + conversation + detail
|
||||
right) at >=900px and a single column below that; other tabs are
|
||||
a single stacked column. Workbench columns keep the equal-height
|
||||
rule from the wide/medium blocks (fixed height, internal scroll). */
|
||||
.dash-page-tabs{display:flex}
|
||||
.dash-page-tabs > .tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}
|
||||
.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}
|
||||
.tab-workbench > *{min-width:0}
|
||||
@media (max-width:899px){
|
||||
.tab-workbench{grid-template-columns:1fr}
|
||||
/* Narrow workbench keeps the old single-column order (dispatch
|
||||
first, then tasks, then conversation) even though the DOM now
|
||||
groups tasks in the left column wrapper. */
|
||||
.tab-workbench #sec-dispatch{order:1} .tab-workbench #sec-tasks{order:2} .tab-workbench #sec-conv{order:3}
|
||||
}
|
||||
.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}
|
||||
.dash-page > *{min-width:0} /* grid/flex children must shrink, never push the page wider; long lines scroll inside pre/.trace-box/.table-wrap instead */
|
||||
.dash-section > *{min-width:0}
|
||||
|
||||
Reference in new issue
Block a user