待办:项目待办模型 + /projects/{id}/todos 接口 + 控制台待办页
This commit is contained in:
1 parent
c10fda4e4a
commit
b3b3b35042
8 files changed
+962
-23
No files matched your search
@@ -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<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user