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
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Bell, Check, TicketIcon, Inbox, Clock } from "../components/icons";
|
||||
import { Button, EmptyState, PageHeader, SkeletonRows } from "../components/ui";
|
||||
import { api, ApiError } from "../lib/client";
|
||||
import type { AppNotification } from "../lib/types";
|
||||
|
||||
const PAGE = 30;
|
||||
|
||||
/** 工单类通知点开跳到工单页,其余回任务页——都带 `?open=`,落地即展开那一条。 */
|
||||
function targetOf(wsId: string, n: AppNotification): string {
|
||||
if (!n.item_id) return `/w/${wsId}/notifications`;
|
||||
const isTicket = n.kind.startsWith("ticket_");
|
||||
return isTicket ? `/w/${wsId}/tickets?open=${n.item_id}` : `/w/${wsId}?open=${n.item_id}`;
|
||||
}
|
||||
|
||||
function iconOf(kind: string) {
|
||||
if (kind.startsWith("ticket_")) return <TicketIcon size={15} />;
|
||||
if (kind === "item_commented") return <Inbox size={15} />;
|
||||
return <Bell size={15} />;
|
||||
}
|
||||
|
||||
function ago(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diff = Math.max(0, Date.now() - then);
|
||||
const min = Math.floor(diff / 60_000);
|
||||
if (min < 1) return "刚刚";
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hour = Math.floor(min / 60);
|
||||
if (hour < 24) return `${hour} 小时前`;
|
||||
const day = Math.floor(hour / 24);
|
||||
if (day < 30) return `${day} 天前`;
|
||||
return new Date(iso).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
/** 通知页:默认只看未读——"全部"是查历史用的,不是日常入口。 */
|
||||
export function NotificationsPage() {
|
||||
const { wsId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<"unread" | "all">("unread");
|
||||
const [rows, setRows] = useState<AppNotification[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (reset: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
unread_only: tab === "unread" ? "true" : "false",
|
||||
limit: String(PAGE),
|
||||
});
|
||||
if (!reset && cursor) params.set("cursor", cursor);
|
||||
const page = await api.get<{
|
||||
notifications: AppNotification[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}>(`/workspaces/${wsId}/notifications?${params.toString()}`);
|
||||
setRows((prev) => (reset ? page.notifications : [...prev, ...page.notifications]));
|
||||
setCursor(page.next_cursor);
|
||||
setHasMore(page.has_more);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "通知加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, cursor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRows([]);
|
||||
setCursor(null);
|
||||
void load(true);
|
||||
// 只在切 tab / 换工作区时重拉,翻页由按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsId, tab]);
|
||||
|
||||
const unreadCount = useMemo(() => rows.filter((r) => !r.read_at).length, [rows]);
|
||||
|
||||
async function markRead(n: AppNotification) {
|
||||
if (!n.read_at) {
|
||||
try {
|
||||
await api.post(`/workspaces/${wsId}/notifications/${n.id}/read`);
|
||||
} catch {
|
||||
/* 标已读失败不拦着用户点进去看 */
|
||||
}
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.id === n.id ? { ...r, read_at: new Date().toISOString() } : r)),
|
||||
);
|
||||
window.dispatchEvent(new Event("notifications:changed"));
|
||||
}
|
||||
navigate(targetOf(wsId, n));
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
try {
|
||||
await api.post(`/workspaces/${wsId}/notifications/read-all`);
|
||||
setRows((prev) => prev.map((r) => ({ ...r, read_at: r.read_at ?? new Date().toISOString() })));
|
||||
window.dispatchEvent(new Event("notifications:changed"));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell wsId={wsId} active="notifications">
|
||||
<PageHeader
|
||||
eyebrow="事情有动静时,这里会告诉你"
|
||||
title="通知"
|
||||
desc="工单被接受、提交、验收,任务被指派、改状态,有人在你关注的事情下说话,都会落在这里。"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => void load(true)} disabled={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm" onClick={() => void markAll()} disabled={!unreadCount}>
|
||||
全部已读
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
["unread", "未读"],
|
||||
["all", "全部"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs transition ${
|
||||
tab === key
|
||||
? "bg-ink-900 font-medium text-white"
|
||||
: "text-ink-500 hover:bg-ink-100 hover:text-ink-800"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{tab === "unread" && unreadCount > 0 ? (
|
||||
<span className="ml-auto text-2xs text-ink-400">还有 {unreadCount} 条没看</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-3 rounded-xl border border-rust-100 bg-rust-50 px-4 py-3 text-sm text-rust-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<SkeletonRows rows={4} />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<Bell size={20} />}
|
||||
title={tab === "unread" ? "没有未读通知" : "还没有通知"}
|
||||
hint={
|
||||
tab === "unread"
|
||||
? "有人给你派活、或你关注的事情有进展时,会出现在这里。"
|
||||
: "这里会留下所有历史通知,方便回头找。"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rows.map((n) => (
|
||||
<li key={n.id} className={`card ${n.read_at ? "" : "border-primary-100 bg-primary-50/40"}`}>
|
||||
<button
|
||||
onClick={() => void markRead(n)}
|
||||
className="flex w-full items-start gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full ${
|
||||
n.read_at ? "bg-ink-100 text-ink-500" : "bg-primary-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{iconOf(n.kind)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="truncate text-sm font-medium text-ink-900">{n.title}</span>
|
||||
{!n.read_at ? (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-clay-500" />
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0 text-2xs text-ink-400">
|
||||
{ago(n.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
{n.body ? (
|
||||
<span className="mt-1 block truncate text-xs text-ink-500">{n.body}</span>
|
||||
) : null}
|
||||
{n.item_title ? (
|
||||
<span className="mt-1.5 inline-flex items-center gap-1 text-2xs text-ink-400">
|
||||
<Clock size={11} />
|
||||
{n.item_title}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => void load(false)} disabled={loading}>
|
||||
加载更早
|
||||
</Button>
|
||||
</div>
|
||||
) : rows.length > 0 && !hasMore ? (
|
||||
<p className="mt-4 text-center text-2xs text-ink-300">
|
||||
<Check size={11} className="mr-1 inline" />
|
||||
没有更多了
|
||||
</p>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user