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 ; if (kind === "item_commented") return ; return ; } 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([]); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ( } />
{( [ ["unread", "未读"], ["all", "全部"], ] as const ).map(([key, label]) => ( ))} {tab === "unread" && unreadCount > 0 ? ( 还有 {unreadCount} 条没看 ) : null}
{error ? (
{error}
) : null} {loading && rows.length === 0 ? ( ) : rows.length === 0 ? (
} title={tab === "unread" ? "没有未读通知" : "还没有通知"} hint={ tab === "unread" ? "有人给你派活、或你关注的事情有进展时,会出现在这里。" : "这里会留下所有历史通知,方便回头找。" } />
) : (
    {rows.map((n) => (
  • ))}
)} {hasMore ? (
) : rows.length > 0 && !hasMore ? (

没有更多了

) : null}
); }