PMFlow/frontend/src/pages/InboxPage.tsx

451 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BellRing } from "lucide-react";
import { aiPmoApi } from "@/api/aiPmo";
import { notificationsApi } from "@/api/notifications";
import { projectsApi } from "@/api/projects";
import InboxActionPanel, { type InboxProjectOption } from "@/components/inbox/InboxActionPanel";
import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/toast";
import { getApiErrorMessage } from "@/lib/apiError";
import { parseBackendDate } from "@/lib/datetime";
import { buildInboxView } from "@/lib/inbox";
import { cn } from "@/lib/utils";
import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
const POLL_INTERVAL_MS = 15000;
type InboxTabKey = "all" | "needs_action" | "in_progress" | "done";
const TAB_LABELS: Record<InboxTabKey, string> = {
all: "全部",
needs_action: "待处理",
in_progress: "进行中",
done: "已完成",
};
const TAB_TO_DISPLAY_KIND: Partial<Record<InboxTabKey, InboxDisplayKind>> = {
needs_action: "action_required",
in_progress: "auto_running",
done: "auto_done",
};
function normalizeTabKey(raw?: string | null): InboxTabKey {
const value = String(raw || "").trim();
if (value === "action_required" || value === "needs_action") return "needs_action";
if (value === "auto_running" || value === "in_progress") return "in_progress";
if (value === "auto_done" || value === "done") return "done";
return "all";
}
function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] {
const toTimestamp = (item: InboxItemViewModel): number => {
const date = parseBackendDate(item.updated_at || item.created_at);
return date ? date.getTime() : -1;
};
return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a));
}
function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean {
if (tab === "all") return true;
return item.display_kind === TAB_TO_DISPLAY_KIND[tab];
}
function parsePositiveInt(value: unknown): number | null {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
const integer = Math.trunc(parsed);
return integer > 0 ? integer : null;
}
function isMissingProjectScopeItem(item: InboxItemViewModel): boolean {
if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return false;
const state = String(item.source_work_item.state || "").toLowerCase();
const gateCode = String(item.latest_policy_gate_code || item.source_work_item.latest_policy_gate_code || "").toLowerCase();
return state === "waiting_manual" && gateCode === "missing_project_scope";
}
export default function InboxPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const [selectedProjectByItem, setSelectedProjectByItem] = useState<Record<string, string>>({});
const [autoExpandedByQuery, setAutoExpandedByQuery] = useState(false);
const activeTab = normalizeTabKey(searchParams.get("section"));
const projectFilter = searchParams.get("project_id") || "all";
const searchValue = searchParams.get("q") || "";
const notificationsQuery = useQuery({
queryKey: ["notifications", "inbox"],
queryFn: () => notificationsApi.list({ limit: 80 }),
refetchInterval: POLL_INTERVAL_MS,
});
const workItemsQuery = useQuery({
queryKey: ["ai-pmo-work-items", "inbox"],
queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }),
refetchInterval: POLL_INTERVAL_MS,
});
const activeProjectsQuery = useQuery({
queryKey: ["projects", "active", "inbox-inline-actions"],
queryFn: () => projectsApi.list({ status: "active" }),
refetchInterval: POLL_INTERVAL_MS,
});
const inboxData = useMemo(() => {
return buildInboxView({
notifications: notificationsQuery.data || { items: [], unread_count: 0 },
workItems: workItemsQuery.data || { items: [] },
});
}, [notificationsQuery.data, workItemsQuery.data]);
const projectOptions = useMemo(() => {
const map = new Map<string, string>();
for (const item of inboxData.items) {
if (!item.project_id) continue;
map.set(String(item.project_id), item.project_name || `项目 #${item.project_id}`);
}
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
}, [inboxData.items]);
const activeProjectOptions = useMemo<InboxProjectOption[]>(() => {
const projects = activeProjectsQuery.data || [];
return projects.map((project) => ({
value: String(project.id),
label: String(project.title || `项目 #${project.id}`),
}));
}, [activeProjectsQuery.data]);
const projectScopedItems = useMemo(() => {
return inboxData.items.filter((item) => {
if (projectFilter === "all") return true;
return String(item.project_id || "") === projectFilter;
});
}, [inboxData.items, projectFilter]);
const tabCounts = useMemo(() => {
return {
all: projectScopedItems.length,
needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length,
} satisfies Record<InboxTabKey, number>;
}, [projectScopedItems]);
const searchedItems = useMemo(() => {
const query = searchValue.trim().toLowerCase();
if (!query) return projectScopedItems;
return projectScopedItems.filter((item) => {
return `${item.title} ${item.summary} ${item.project_name || ""} ${item.stage_text || ""}`
.toLowerCase()
.includes(query);
});
}, [projectScopedItems, searchValue]);
const feedItems = useMemo(() => {
const tabItems = searchedItems.filter((item) => matchesTab(item, activeTab));
return sortByLatest(tabItems);
}, [searchedItems, activeTab]);
useEffect(() => {
if (autoExpandedByQuery) return;
const targetId = parsePositiveInt(searchParams.get("workItemId"));
if (!targetId) return;
const targetKey = `work-item-${targetId}`;
const matched = feedItems.find((item) => item.id === targetKey);
if (matched) {
setExpandedItemId(targetKey);
setAutoExpandedByQuery(true);
}
}, [autoExpandedByQuery, feedItems, searchParams]);
const setTab = (tab: string) => {
const normalized = normalizeTabKey(tab);
const next = new URLSearchParams(searchParams);
next.set("section", normalized);
setSearchParams(next);
};
const setProject = (projectId: string) => {
const next = new URLSearchParams(searchParams);
if (projectId === "all") next.delete("project_id");
else next.set("project_id", projectId);
setSearchParams(next);
};
const setSearch = (value: string) => {
const next = new URLSearchParams(searchParams);
if (!value.trim()) next.delete("q");
else next.set("q", value.trim());
setSearchParams(next);
};
const toggleExpand = (itemId: string) => {
setExpandedItemId((current) => (current === itemId ? null : itemId));
};
const getSelectedProjectId = (item: InboxItemViewModel): number | null => {
const selected = selectedProjectByItem[item.id];
if (selected) return parsePositiveInt(selected);
if (item.project_id) return parsePositiveInt(item.project_id);
return null;
};
const getSelectedProjectValue = (item: InboxItemViewModel): string => {
const selected = selectedProjectByItem[item.id];
if (selected) return selected;
return item.project_id ? String(item.project_id) : "";
};
const setSelectedProjectValue = (item: InboxItemViewModel, value: string) => {
setSelectedProjectByItem((current) => ({ ...current, [item.id]: value }));
};
const refreshAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
queryClient.invalidateQueries({ queryKey: ["ai-pmo-work-items", "inbox"] }),
]);
};
const markNotificationRead = async (item: InboxItemViewModel) => {
if (item.source_kind === "notification" && item.source_notification && !item.source_notification.is_read) {
try {
await notificationsApi.markRead(Number(item.source_notification.id));
} catch {
// 不阻断后续操作。
}
}
};
const openTarget = async (item: InboxItemViewModel) => {
await markNotificationRead(item);
if (item.target_url) {
if (item.target_url.startsWith("/")) navigate(item.target_url);
else window.location.assign(item.target_url);
await refreshAll();
return;
}
toast({ title: "当前消息暂无可打开的目标入口", variant: "default" });
};
const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => {
const actionKey = buildInboxActionKey(item, action);
setPendingActionKey(actionKey);
try {
if (action.kind === "open_target") {
await openTarget(item);
return;
}
if (action.kind === "open_detail") {
await markNotificationRead(item);
toggleExpand(item.id);
await refreshAll();
return;
}
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
const missingProjectScope = isMissingProjectScopeItem(item);
const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null);
if (missingProjectScope && !selectedProjectId) {
setExpandedItemId(item.id);
toast({ title: "请先选择关联项目后再确认", variant: "default" });
return;
}
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
action: "approve",
project_id: selectedProjectId ?? undefined,
});
await refreshAll();
const followupReply = String(result.followup_reply || "").trim();
if (result.status === "succeeded") {
toast({ title: followupReply || "已继续执行并完成", variant: "success" });
} else if (result.status === "pending_manual") {
toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" });
} else {
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
}
setSelectedProjectByItem((current) => {
if (!(item.id in current)) return current;
const next = { ...current };
delete next[item.id];
return next;
});
setExpandedItemId(item.id);
return;
}
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
action: "reject",
reason: "人工审批拒绝,已标记失效",
});
await refreshAll();
const followupReply = String(result.followup_reply || "").trim();
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
return;
}
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
await notificationsApi.markRead(item.source_notification.id);
await refreshAll();
return;
}
if (action.kind === "none") {
toggleExpand(item.id);
}
} catch (error) {
toast({ title: getApiErrorMessage(error), variant: "error" });
} finally {
setPendingActionKey((current) => (current === actionKey ? null : current));
}
};
const markAllRead = async () => {
try {
await notificationsApi.markAllRead();
await refreshAll();
toast({ title: "站内通知已全部设为已读", variant: "success" });
} catch (error) {
toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" });
}
};
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
const activeProjectErrorText = activeProjectsQuery.isError
? getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败")
: null;
return (
<div className="space-y-4 pb-12">
<section className="rounded-2xl bg-[#F8F9FA] p-3 md:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight text-slate-900 md:text-2xl">消息台</h1>
<p className="mt-1 text-xs text-slate-500 md:text-sm">列表 Feed · 最新在上 · 支持移动端</p>
<p className="mt-1 text-xs text-slate-500">当前结果:{feedItems.length} 条</p>
</div>
<Button
variant="outline"
className="h-9 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
onClick={markAllRead}
disabled={notificationsQuery.data?.unread_count === 0}
>
全部已读
</Button>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
<Input
placeholder="搜索标题、副标题、项目"
defaultValue={searchValue}
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
onBlur={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value);
}}
/>
<select
value={projectFilter}
onChange={(event) => setProject(event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
>
<option value="all">全部项目</option>
{projectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="mt-3">
<div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
<button
key={tabKey}
type="button"
onClick={() => setTab(tabKey)}
className={cn(
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
activeTab === tabKey
? "border-slate-300 bg-white text-slate-900"
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white"
)}
>
<span>{TAB_LABELS[tabKey]}</span>
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
</button>
))}
</div>
</div>
</div>
</section>
{isLoading ? (
<section className="rounded-2xl bg-[#F8F9FA] p-4">
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
加载消息台中…
</div>
</section>
) : feedItems.length === 0 ? (
<section className="rounded-2xl bg-[#F8F9FA] p-4">
<div className="rounded-xl bg-white px-4 py-10 text-center text-slate-500">
<BellRing className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="font-medium text-slate-700">当前没有匹配收件项</p>
<p className="mt-1 text-sm">请调整筛选条件后重试</p>
</div>
</section>
) : (
<section className="space-y-2 rounded-2xl bg-[#F8F9FA] p-2 md:p-3">
{feedItems.map((item) => (
<InboxListItem
key={item.id}
item={item}
expanded={expandedItemId === item.id}
pendingActionKey={pendingActionKey}
inlinePanel={
expandedItemId === item.id && isMissingProjectScopeItem(item) ? (
<InboxActionPanel
selectedProjectId={getSelectedProjectValue(item)}
projectOptions={activeProjectOptions}
loadingProjects={activeProjectsQuery.isLoading}
projectsErrorText={activeProjectErrorText}
pending={pendingActionKey === buildInboxActionKey(item, item.primary_action)}
onSelectProject={(value) => setSelectedProjectValue(item, value)}
onCancel={() => setExpandedItemId(null)}
onConfirm={() => void handleAction(item, item.primary_action)}
/>
) : undefined
}
onToggleExpand={toggleExpand}
onAction={(target, action) => void handleAction(target, action)}
/>
))}
</section>
)}
</div>
);
}