CI / backend (push) Canceled after 0s
M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。 通知(backend/app/services/notify.py 统一出口) - 七类触发:task_assigned、task_status_changed、item_commented、 ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked - 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断 - 已读:GET /notifications、/unread-count、POST /read、/read-all 手工开单 - POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item, ticket_ext.parent_item_id 为空,验收通过不回流任务 - 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上") 前端 - Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标); 工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新; 任务与工单支持 ?open= 深链落地即展开 - 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框 验证 - pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例) - ruff check / format --check 全 0 - tsc --noEmit 通过;vite build 成功 - web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零) - 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证, 装回 JDK 17 后第一件事是 assembleDebug
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useParams, useSearchParams } from "react-router-dom";
|
|
import { AppShell } from "../components/AppShell";
|
|
import { TaskDetail } from "../components/TaskDetail";
|
|
import { Check, Inbox, Plus, Search } from "../components/icons";
|
|
import {
|
|
Button,
|
|
EmptyState,
|
|
InlineAlert,
|
|
PageHeader,
|
|
PriorityDot,
|
|
SkeletonRows,
|
|
StatusPill,
|
|
} from "../components/ui";
|
|
import { ApiError, api } from "../lib/client";
|
|
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
|
|
import type { Item, ItemStatus, Page } from "../lib/types";
|
|
|
|
const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [
|
|
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" },
|
|
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" },
|
|
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" },
|
|
{ key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" },
|
|
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
|
|
];
|
|
|
|
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
|
|
if (!iso) return null;
|
|
const due = new Date(iso);
|
|
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
|
return { text, overdue: due.getTime() < Date.now() };
|
|
}
|
|
|
|
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);
|
|
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
|
const [activeIdx, setActiveIdx] = useState(0);
|
|
const reqId = useRef(0);
|
|
|
|
const current = VIEWS.find((v) => v.key === view);
|
|
|
|
const load = useCallback(
|
|
async (nextCursor?: string | null) => {
|
|
if (!wsId) return;
|
|
// 换视图/改搜索词会连发多个请求,慢的那个后到会把新结果覆盖掉。
|
|
// 每次请求记一个号,回来时不是最新号就整包丢弃。
|
|
const mine = ++reqId.current;
|
|
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}`);
|
|
if (mine !== reqId.current) return;
|
|
setItems((prev) => (nextCursor ? [...prev, ...page.items] : page.items));
|
|
setCursor(page.next_cursor);
|
|
setHasMore(page.has_more);
|
|
setError(null);
|
|
} catch (err) {
|
|
if (mine !== reqId.current) return;
|
|
setError(err instanceof ApiError ? err.message : "加载失败");
|
|
} finally {
|
|
if (mine === reqId.current) setLoading(false);
|
|
}
|
|
},
|
|
[wsId, view, query],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void load(null);
|
|
}, [load]);
|
|
|
|
const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
const typing =
|
|
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
|
|
if (e.key === "Escape") {
|
|
setOpenId(null);
|
|
(target as HTMLInputElement).blur?.();
|
|
return;
|
|
}
|
|
if (typing || items.length === 0) return;
|
|
|
|
if (e.key === "j" || e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActiveIdx((i) => Math.min(i + 1, items.length - 1));
|
|
} else if (e.key === "k" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActiveIdx((i) => Math.max(i - 1, 0));
|
|
} else if (e.key === "e") {
|
|
if (activeItem) setOpenId(activeItem.id);
|
|
} else if (e.key === "Enter") {
|
|
if (activeItem) void changeStatus(activeItem, activeItem.status === "done" ? "todo" : "done");
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
});
|
|
|
|
async function addTask() {
|
|
const raw = draft.trim();
|
|
if (!raw || !wsId || !current) return;
|
|
const parsed = parseQuickAdd(raw, current.createAs);
|
|
try {
|
|
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
|
title: parsed.title,
|
|
status: parsed.status,
|
|
priority: parsed.priority,
|
|
tag_names: parsed.tagNames,
|
|
});
|
|
setDraft("");
|
|
if (view !== "done") setItems((prev) => [created, ...prev]);
|
|
} 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)));
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.code === 4009) {
|
|
setError("这条任务刚被别人改过,已为你刷新");
|
|
void load(null);
|
|
return;
|
|
}
|
|
setError(err instanceof ApiError ? err.message : "状态更新失败");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AppShell wsId={wsId} active="tasks">
|
|
<PageHeader
|
|
title="任务"
|
|
desc={current?.hint}
|
|
eyebrow={<>按 j / k 移动,Enter 完成,e 打开详情</>}
|
|
actions={
|
|
<div className="relative">
|
|
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400">
|
|
<Search size={14} />
|
|
</span>
|
|
<input
|
|
className="input h-9 w-56 pl-8 text-sm"
|
|
placeholder="搜索标题或正文…"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
|
{VIEWS.map((v) => {
|
|
const active = view === v.key;
|
|
return (
|
|
<button
|
|
key={v.key}
|
|
onClick={() => setView(v.key)}
|
|
className={`rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
|
|
active
|
|
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
|
: "text-ink-500 hover:bg-white/60 hover:text-ink-800"
|
|
}`}
|
|
>
|
|
{v.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="card mb-4 overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full border border-dashed border-line-300 text-ink-400">
|
|
<Plus size={14} />
|
|
</span>
|
|
<input
|
|
className="input-flush flex-1"
|
|
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") void addTask();
|
|
}}
|
|
/>
|
|
<Button variant="primary" size="sm" disabled={!draft.trim()} onClick={() => void addTask()}>
|
|
添加
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4">
|
|
<InlineAlert>{error}</InlineAlert>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card overflow-hidden p-0">
|
|
{loading && items.length === 0 ? (
|
|
<SkeletonRows rows={5} />
|
|
) : items.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Inbox size={18} />}
|
|
title={view === "today" ? "今天没有到期的事,挺好" : "这里还空着"}
|
|
hint={
|
|
view === "today"
|
|
? "想提前安排?切到「进行中」,或直接在上面加一件。"
|
|
: "在上面输入一件要做的事,回车就进列表。"
|
|
}
|
|
/>
|
|
) : (
|
|
items.map((item, index) => {
|
|
const due = dueLabel(item.due_at);
|
|
const done = item.status === "done";
|
|
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
|
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className={`group flex items-start gap-3 border-b border-line-100 px-4 py-3 transition-colors last:border-0 ${
|
|
index === activeIdx ? "bg-primary-50/45" : "hover:bg-paper-50"
|
|
}`}
|
|
onMouseEnter={() => setActiveIdx(index)}
|
|
>
|
|
{canComplete ? (
|
|
<button
|
|
onClick={() => void changeStatus(item, done ? "todo" : "done")}
|
|
aria-label={done ? "标记未完成" : "标记完成"}
|
|
className={`mt-0.5 grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border transition ${
|
|
done
|
|
? "border-sage-500 bg-sage-500 text-white"
|
|
: "border-line-300 text-transparent hover:border-primary-500 hover:text-primary-500"
|
|
}`}
|
|
>
|
|
<Check size={11} strokeWidth={2.4} />
|
|
</button>
|
|
) : (
|
|
<span
|
|
className="mt-0.5 h-[18px] w-[18px] shrink-0 rounded-full border border-dashed border-line-300"
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
|
|
{/* 宽屏把元信息推到右侧,一行读完;窄屏仍折到标题下面 */}
|
|
<div className="min-w-0 flex-1 md:flex md:items-center md:gap-3">
|
|
<button
|
|
className={`block w-full truncate text-left text-sm transition-colors md:flex-1 ${
|
|
done ? "text-ink-400 line-through" : "text-ink-900 hover:text-primary-700"
|
|
}`}
|
|
onClick={() => setOpenId(item.id)}
|
|
>
|
|
{item.title}
|
|
</button>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-2xs md:mt-0 md:shrink-0 md:justify-end">
|
|
<StatusPill status={item.status} label={STATUS_LABEL[item.status]} />
|
|
{item.priority > 0 && (
|
|
<span className="chip">
|
|
<PriorityDot level={item.priority} />
|
|
优先级 {PRIORITY_LABEL[item.priority]}
|
|
</span>
|
|
)}
|
|
{due && (
|
|
<span
|
|
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 ${
|
|
due.overdue && !done
|
|
? "bg-rust-50 font-medium text-rust-500"
|
|
: "text-ink-400"
|
|
}`}
|
|
>
|
|
截止 {due.text}
|
|
{due.overdue && !done ? "(逾期)" : ""}
|
|
</span>
|
|
)}
|
|
{item.tags.map((tag) => (
|
|
<span key={tag.id} className="chip text-ink-500">
|
|
#{tag.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
{item.status === "inbox" && (
|
|
<Button variant="ghost" size="sm" onClick={() => void changeStatus(item, "todo")}>
|
|
收下
|
|
</Button>
|
|
)}
|
|
{(item.status === "todo" || item.status === "blocked") && (
|
|
<Button variant="ghost" size="sm" onClick={() => void changeStatus(item, "doing")}>
|
|
开工
|
|
</Button>
|
|
)}
|
|
{item.status === "doing" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void changeStatus(item, "blocked")}
|
|
>
|
|
阻塞
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{hasMore && (
|
|
<div className="mt-4 text-center">
|
|
<Button variant="ghost" disabled={loading} onClick={() => void load(cursor)}>
|
|
{loading ? "加载中…" : "加载更多"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{openId && wsId && (
|
|
<TaskDetail
|
|
wsId={wsId}
|
|
itemId={openId}
|
|
onClose={() => {
|
|
setOpenId(null);
|
|
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
|
}}
|
|
onChanged={(updated) =>
|
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
|
}
|
|
onDeleted={(id) => setItems((prev) => prev.filter((it) => it.id !== id))}
|
|
/>
|
|
)}
|
|
</AppShell>
|
|
);
|
|
}
|