Files
DadaoTask/web/src/pages/Tasks.tsx
T
Steven 91b3dde670
CI / backend (push) Canceled after 0s
feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
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
2026-10-30 02:19:42 +08:00

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>
);
}