Files
codex-app-server/cloud/console-react/src/sections/TodosSection.tsx
T

148 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
pending: "待办", in_progress: "进行中", blocked: "受阻",
completed: "已完成", cancelled: "已取消",
};
const PRIORITIES = ["low", "medium", "high"];
const PRIORITY_LABEL: Record<string, string> = { 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<Todo[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
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<string, number>; 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<string, unknown> = { 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<string, unknown>) => {
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 (
<Card id="nav-todos" title="项目待办">
<div className="muted">
{ctx.projectId
? "共 " + total + " 项 | " + STATUSES.map((s) => STATUS_LABEL[s] + " " + (counts[s] || 0)).join(" | ")
: "先选项目"}
</div>
{err ? <div className="err">{err}</div> : null}
<Bar>
<select aria-label="状态筛选" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">状态:全部</option>
{STATUSES.map((s) => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
</select>
<input type="text" placeholder="搜索标题/说明" value={q} onChange={(e) => setQ(e.target.value)} />
<button onClick={() => void load()}>刷新</button>
</Bar>
<input type="text" placeholder="待办标题(必填)" style={{ width: "100%" }} value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea placeholder="说明(可选)" value={description} onChange={(e) => setDescription(e.target.value)} />
<Bar>
<select aria-label="优先级" value={priority} onChange={(e) => setPriority(e.target.value)}>
{PRIORITIES.map((p) => <option key={p} value={p}>优先级:{PRIORITY_LABEL[p]}</option>)}
</select>
<input type="text" placeholder="关联任务 id(可选)" value={taskId} onChange={(e) => setTaskId(e.target.value)} />
<input type="datetime-local" aria-label="截止时间" value={due} onChange={(e) => setDue(e.target.value)} />
<button className="primary" onClick={() => void create()}>新建待办</button>
<span className="muted">{msg}</span>
</Bar>
<DataList empty={ctx.projectId ? "(暂无待办)" : "(先选项目)"} items={todos.map((t) => (
<span key={t.id} className="row-main">
<b>{t.title}</b>
{" | "}<span className={"st-" + t.status}>{STATUS_LABEL[t.status] || t.status}</span>
{" | 优先级 " + (PRIORITY_LABEL[t.priority] || t.priority)}
{t.task_id ? " | 任务 " + shortId(t.task_id) : ""}
{t.due_at ? " | 截止 " + fmtDue(t.due_at) : ""}
{t.completed_at ? " | 完成于 " + new Date(t.completed_at * 1000).toLocaleString() : ""}
{t.description ? <><br /><span className="muted">{t.description}</span></> : null}
<br />
<select aria-label="改状态" value={t.status} onChange={(e) => void patch(t, { status: e.target.value })}>
{STATUSES.map((s) => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
</select>
{" "}<button onClick={() => void patch(t, { status: "completed" })}>完成</button>
{" "}<button onClick={() => void remove(t)}>删除</button>
</span>
))} />
<div className="muted">状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。</div>
</Card>
);
}