451 lines
18 KiB
TypeScript
451 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|