feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
CI / backend (push) Canceled after 0s

- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
This commit is contained in:
Steven
2026-10-28 03:48:48 +08:00
parent d390b7b58a
commit e25a1a4e31
19 changed files with 1959 additions and 31 deletions
+3 -1
View File
@@ -5,6 +5,7 @@ import type { User, Workspace } from "./lib/types";
import { LoginPage } from "./pages/Login";
import { HomePage } from "./pages/Home";
import { WorkspacePage } from "./pages/Workspace";
import { TasksPage } from "./pages/Tasks";
import { JoinPage } from "./pages/Join";
interface AuthState {
@@ -80,7 +81,8 @@ export function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/w/:wsId" element={<WorkspacePage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+37
View File
@@ -38,3 +38,40 @@ export interface Invitation {
role: Role;
expires_at: string;
}
export type ItemStatus = "inbox" | "todo" | "doing" | "done" | "blocked" | "canceled";
export interface Tag {
id: string;
name: string;
color: string;
}
export interface Item {
id: string;
kind: "task" | "ticket" | "knowledge";
title: string;
body_md: string;
status: ItemStatus;
priority: number;
creator_id: string;
assignee_id: string | null;
due_at: string | null;
completed_at: string | null;
tags: Tag[];
created_at: string;
updated_at: string;
version: number;
}
export interface Activity {
id: string;
action: string;
actor_id: string | null;
payload: Record<string, unknown> | null;
created_at: string;
}
export interface ItemDetail extends Item {
activities: Activity[];
}
+291
View File
@@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { ApiError, api } from "../lib/client";
import type { Item, ItemDetail, ItemStatus, Page } from "../lib/types";
const VIEWS: { key: string; label: string; hint: string }[] = [
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的" },
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" },
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事" },
{ key: "done", label: "已完成", hint: "最近完成的" },
{ key: "all", label: "全部", hint: "含已取消" },
];
const STATUS_LABEL: Record<ItemStatus, string> = {
inbox: "收件箱",
todo: "待办",
doing: "进行中",
done: "已完成",
blocked: "被阻塞",
canceled: "已取消",
};
const STATUS_STYLE: Record<ItemStatus, string> = {
inbox: "bg-ink-100 text-ink-700",
todo: "bg-ink-100 text-ink-700",
doing: "bg-accent/15 text-accent",
done: "bg-emerald-100 text-emerald-700",
blocked: "bg-amber-100 text-amber-700",
canceled: "bg-ink-100 text-ink-500 line-through",
};
function formatDue(iso: string | null): { text: string; overdue: boolean } | null {
if (!iso) return null;
const due = new Date(iso);
const now = new Date();
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
return { text, overdue: due.getTime() < now.getTime() };
}
export function TasksPage() {
const { wsId } = useParams<{ wsId: string }>();
const [view, setView] = useState("today");
const [items, setItems] = useState<Item[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [query, setQuery] = useState("");
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [detail, setDetail] = useState<ItemDetail | null>(null);
const draftRef = useRef<HTMLInputElement>(null);
const load = useCallback(
async (nextCursor?: string | null) => {
if (!wsId) return;
setLoading(true);
try {
const params = new URLSearchParams({ view, limit: "30" });
if (query.trim()) params.set("q", query.trim());
if (nextCursor) params.set("cursor", nextCursor);
const page = await api.get<Page<Item>>(`/workspaces/${wsId}/items?${params}`);
setItems((prev) => (nextCursor ? [...prev, ...page.items] : page.items));
setCursor(page.next_cursor);
setHasMore(page.has_more);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
},
[wsId, view, query],
);
useEffect(() => {
void load(null);
}, [load]);
useEffect(() => {
if (!openId || !wsId) {
setDetail(null);
return;
}
void api
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
.then(setDetail)
.catch(() => setDetail(null));
}, [openId, wsId]);
async function addTask() {
const title = draft.trim();
if (!title || !wsId) return;
try {
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
title,
status: view === "inbox" ? "inbox" : "todo",
});
setDraft("");
setItems((prev) => [created, ...prev]);
draftRef.current?.focus();
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
}
}
async function changeStatus(item: Item, status: ItemStatus) {
if (!wsId) return;
try {
const updated = await api.post<Item>(`/workspaces/${wsId}/items/${item.id}/status`, {
status,
version: item.version,
});
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev));
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("这条任务刚被别人改过,已为你刷新");
void load(null);
return;
}
setError(err instanceof ApiError ? err.message : "状态更新失败");
}
}
const current = VIEWS.find((v) => v.key === view);
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">任务</h1>
<p className="mt-0.5 text-xs text-ink-500">{current?.hint}</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
成员与邀请
</Link>
<Link to="/" className="text-ink-500 hover:text-ink-900">
切换工作区
</Link>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
{VIEWS.map((v) => (
<button
key={v.key}
onClick={() => setView(v.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
view === v.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{v.label}
</button>
))}
<input
className="input ml-auto w-48"
placeholder="搜索标题或正文…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="card mb-4">
<div className="flex gap-2">
<input
ref={draftRef}
className="input flex-1"
placeholder="加一件要做的事,回车创建"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addTask();
}}
/>
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addTask()}>
添加
</button>
</div>
</div>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<div className="card divide-y divide-ink-100 p-0">
{items.length === 0 && !loading && (
<p className="p-6 text-center text-sm text-ink-500">
{view === "today" ? "今天没有到期的事,挺好。" : "这里还空着。"}
</p>
)}
{items.map((item) => {
const due = formatDue(item.due_at);
const done = item.status === "done";
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
const canComplete = item.status !== "inbox" && item.status !== "canceled";
return (
<div key={item.id} className="flex items-start gap-3 px-4 py-3">
{canComplete ? (
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-ink-900"
checked={done}
onChange={() => void changeStatus(item, done ? "todo" : "done")}
aria-label={done ? "标记未完成" : "标记完成"}
/>
) : (
<span className="mt-1 h-4 w-4 rounded border border-ink-100" aria-hidden />
)}
<div className="min-w-0 flex-1">
<button
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
onClick={() => setOpenId(openId === item.id ? null : item.id)}
>
{item.title}
</button>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
{STATUS_LABEL[item.status]}
</span>
{due && (
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
截止 {due.text}
{due.overdue && !done ? "(逾期)" : ""}
</span>
)}
{item.tags.map((tag) => (
<span key={tag.id} className="rounded bg-ink-50 px-1.5 py-0.5 text-ink-700">
#{tag.name}
</span>
))}
</div>
{openId === item.id && (
<div className="mt-3 rounded-lg bg-ink-50 p-3 text-xs">
{detail && detail.id === item.id ? (
<>
{detail.body_md ? (
<p className="whitespace-pre-wrap text-ink-700">{detail.body_md}</p>
) : (
<p className="text-ink-500">没有备注。</p>
)}
<ul className="mt-3 space-y-1 text-ink-500">
{detail.activities.slice(0, 8).map((a) => (
<li key={a.id}>
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
</li>
))}
</ul>
</>
) : (
<p className="text-ink-500">加载中…</p>
)}
</div>
)}
</div>
<div className="flex shrink-0 gap-1">
{item.status === "inbox" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "todo")}>
收下
</button>
)}
{(item.status === "todo" || item.status === "blocked") && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "doing")}>
开工
</button>
)}
{item.status === "doing" && (
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => void changeStatus(item, "blocked")}>
阻塞
</button>
)}
</div>
</div>
);
})}
</div>
{hasMore && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</div>
)}
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
</Layout>
);
}
+2 -2
View File
@@ -60,8 +60,8 @@ export function WorkspacePage() {
return (
<Layout>
<div className="mb-4 flex items-center gap-3 text-sm text-ink-500">
<Link to="/" className="hover:text-ink-900">
← 工作区
<Link to={`/w/${wsId}`} className="hover:text-ink-900">
← 任务
</Link>
{ws && <span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs">{ROLE_LABEL[ws.role]}</span>}
</div>