diff --git a/cloud/console-react/src/App.tsx b/cloud/console-react/src/App.tsx index b693cb6..8d26c16 100644 --- a/cloud/console-react/src/App.tsx +++ b/cloud/console-react/src/App.tsx @@ -19,6 +19,7 @@ import SkillsSection from "./sections/SkillsSection"; import SkillsToolsPanel from "./sections/SkillsToolsPanel"; import AutomationsSection from "./sections/AutomationsSection"; import TriageSection from "./sections/TriageSection"; +import TodosSection from "./sections/TodosSection"; // Redesign (2026-10-30): the old layout is gone — no top tab bar, no // data-cards row on top of a long stacked page, no detail stack @@ -35,9 +36,10 @@ import TriageSection from "./sections/TriageSection"; // unmounted with a view. The view is synced to location.hash // (#view-tasks etc.) so a reload or a shared link lands on the // same view; unknown hashes fall back to the task workspace. -type ViewId = "tasks" | "projects" | "resources" | "automation" | "admin"; +type ViewId = "tasks" | "todos" | "projects" | "resources" | "automation" | "admin"; const VIEWS: Array<[ViewId, string, string]> = [ ["tasks", "任务工作台", "任务"], + ["todos", "项目待办", "任务"], ["projects", "项目与成员", "管理"], ["resources", "环境与 Workers", "管理"], ["automation", "技能与自动化", "管理"], @@ -135,7 +137,8 @@ export default function App() { ) : (
- {view === "projects" ? (<>) + {view === "todos" ? (<>) + : view === "projects" ? (<>) : view === "resources" ? (<>) : view === "automation" ? (<>) : (<>)} diff --git a/cloud/console-react/src/api/paths.ts b/cloud/console-react/src/api/paths.ts index da7edc9..60e7ed2 100644 --- a/cloud/console-react/src/api/paths.ts +++ b/cloud/console-react/src/api/paths.ts @@ -21,6 +21,8 @@ export const paths = { projectAutomations: (pid: string) => "/projects/" + enc(pid) + "/automations", projectAutomationCancel: (pid: string, id: string) => "/projects/" + enc(pid) + "/automations/" + enc(id) + "/cancel", projectTriage: (pid: string) => "/projects/" + enc(pid) + "/triage", + projectTodos: (pid: string) => "/projects/" + enc(pid) + "/todos", + projectTodo: (pid: string, id: string) => "/projects/" + enc(pid) + "/todos/" + enc(id), workers: "/workers", environments: (pid: string) => "/environments?project_id=" + enc(pid), environmentsCreate: "/environments", diff --git a/cloud/console-react/src/sections/TodosSection.tsx b/cloud/console-react/src/sections/TodosSection.tsx new file mode 100644 index 0000000..f7abb2c --- /dev/null +++ b/cloud/console-react/src/sections/TodosSection.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useState } from "react"; +import { useConsole } from "../context"; +import { api, shortId } from "../api/client"; +import { paths } from "../api/paths"; +import { Card, Bar, DataList } from "../components/ui"; + +// Project todos: Muse-style checklist for a project, not a task +// queue. Five statuses (pending / in_progress / blocked / +// completed / cancelled), priority, optional linked task id and +// due time; completed_at is set/cleared by the server on status +// change. This section fetches directly (like the automation +// panel's create/refresh pattern) instead of going through the +// store, so no existing store hook has to change. +type Todo = { + id: string; project_id: string; title: string; description: string; + status: string; priority: string; task_id: string | null; + due_at: number | null; sort_order: number | null; + created_by: string; created_at: number; updated_at: number; + completed_at: number | null; +}; +const STATUSES = ["pending", "in_progress", "blocked", "completed", "cancelled"]; +const STATUS_LABEL: Record = { + pending: "待办", in_progress: "进行中", blocked: "受阻", + completed: "已完成", cancelled: "已取消", +}; +const PRIORITIES = ["low", "medium", "high"]; +const PRIORITY_LABEL: Record = { low: "低", medium: "中", high: "高" }; + +function fmtDue(due: number | null): string { + if (!due) return ""; + return new Date(due * 1000).toLocaleString(); +} + +export default function TodosSection() { + const ctx = useConsole(); + const [todos, setTodos] = useState([]); + const [counts, setCounts] = useState>({}); + const [total, setTotal] = useState(0); + const [msg, setMsg] = useState(""); + const [err, setErr] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [q, setQ] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState("medium"); + const [taskId, setTaskId] = useState(""); + const [due, setDue] = useState(""); + + const load = useCallback(async () => { + if (!ctx.projectId) { setTodos([]); setCounts({}); setTotal(0); return; } + try { + let u = paths.projectTodos(ctx.projectId); + const params = new URLSearchParams(); + if (statusFilter) params.set("status", statusFilter); + if (q.trim()) params.set("q", q.trim()); + const qs = params.toString(); + if (qs) u += "?" + qs; + const d = await api<{ todos: Todo[]; counts: Record; total: number }>(u); + setTodos(d.todos || []); + setCounts(d.counts || {}); + setTotal(d.total || 0); + setErr(""); + } catch (e: any) { setErr("待办加载失败:" + (e?.message || String(e))); } + }, [ctx.projectId, statusFilter, q]); + + useEffect(() => { void load(); }, [load]); + + const create = async () => { + if (!ctx.projectId) { setMsg("先选项目"); return; } + if (!title.trim()) { setMsg("标题必填"); return; } + setMsg(""); + try { + const body: Record = { title: title.trim(), description, priority }; + if (taskId.trim()) body.task_id = taskId.trim(); + if (due) body.due_at = Math.floor(new Date(due).getTime() / 1000); + await api(paths.projectTodos(ctx.projectId), "POST", body); + setTitle(""); setDescription(""); setTaskId(""); setDue(""); + setMsg("已创建"); + await load(); + } catch (e: any) { setMsg("创建失败:" + (e?.message || String(e))); } + }; + + const patch = async (t: Todo, fields: Record) => { + if (!ctx.projectId) return; + try { + await api(paths.projectTodo(ctx.projectId, t.id), "PATCH", fields); + await load(); + } catch (e: any) { setErr("待办更新失败:" + (e?.message || String(e))); } + }; + + const remove = async (t: Todo) => { + if (!ctx.projectId) return; + if (!window.confirm("删除待办“" + t.title + "”?删除后不可恢复(取消请改状态为已取消)。")) return; + try { + await api(paths.projectTodo(ctx.projectId, t.id), "DELETE"); + await load(); + } catch (e: any) { setErr("待办删除失败:" + (e?.message || String(e))); } + }; + + return ( + +
+ {ctx.projectId + ? "共 " + total + " 项 | " + STATUSES.map((s) => STATUS_LABEL[s] + " " + (counts[s] || 0)).join(" | ") + : "先选项目"} +
+ {err ?
{err}
: null} + + + setQ(e.target.value)} /> + + + setTitle(e.target.value)} /> +