diff --git a/src/features/workbench/api/operational-task-client.test.ts b/src/features/workbench/api/operational-task-client.test.ts index 7765278..02e99c3 100644 --- a/src/features/workbench/api/operational-task-client.test.ts +++ b/src/features/workbench/api/operational-task-client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + fetchOperationalTaskNotifications, parseOperationalTask, parseOperationalTaskNotification } from "./operational-task-client"; @@ -105,4 +106,30 @@ describe("operational notification contract", () => { resolution_reason: "action_approved" }).lifecycle_status).toBe("resolved"); }); + + it("requests the active inbox instead of hiding already read work", async () => { + let request: Request | undefined; + vi.stubGlobal("fetch", vi.fn(async (input, init) => { + request = new Request(input, init); + return new Response(JSON.stringify({ + items: [{ + ...notification, + lifecycle_status: "active", + status: "read", + version: 2, + read_at: time, + read_by: `principal:${id}` + }], + limit: 100, + offset: 0, + next_offset: null + }), { status: 200 }); + })); + + const items = await fetchOperationalTaskNotifications(); + + expect(items).toHaveLength(1); + expect(items[0]?.status).toBe("read"); + expect(new URL(request!.url).searchParams.get("unread_only")).toBe("false"); + }); }); diff --git a/src/features/workbench/api/operational-task-client.ts b/src/features/workbench/api/operational-task-client.ts index 010ee94..0e666eb 100644 --- a/src/features/workbench/api/operational-task-client.ts +++ b/src/features/workbench/api/operational-task-client.ts @@ -397,18 +397,18 @@ export async function decideOperationalTaskAccess(taskId: string, accessRequestI export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) { return notificationPageSchema.parse(await operationalTaskRequest( - "/operational-task-notifications?unread_only=true&limit=100&offset=0", + "/operational-task-notifications?unread_only=false&limit=100&offset=0", getAccessToken, { signal } )).items; } export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) { - await operationalTaskRequest( + return notificationSchema.parse(await operationalTaskRequest( `/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`, getAccessToken, { method: "POST", body: JSON.stringify({ request_id: requestId, expected_version: version }) } - ); + )); } export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) { diff --git a/src/features/workbench/hooks/use-operational-tasks.test.ts b/src/features/workbench/hooks/use-operational-tasks.test.ts index 0aaf30e..a901945 100644 --- a/src/features/workbench/hooks/use-operational-tasks.test.ts +++ b/src/features/workbench/hooks/use-operational-tasks.test.ts @@ -3,6 +3,7 @@ import type { OperationalTask } from "../api/operational-task-client"; import type { WorkbenchEvent } from "../api/workbench-sync-client"; import { collectOperationalTaskEventChanges, + selectHeaderNotifications, toOperationalTaskCondition } from "./use-operational-tasks"; @@ -123,6 +124,37 @@ describe("operational task event invalidation", () => { }); }); +describe("operational notification visibility", () => { + it("keeps a read notification visible until its business lifecycle is resolved", () => { + const notification = { + notification_id: "66666666-6666-4666-8666-666666666666", + task_id: "22222222-2222-4222-8222-222222222222", + kind: "action_approval_pending" as const, + title: "待审批工单", + description: "请审核派单内容", + required_permission: null, + lifecycle_status: "active" as const, + subject_type: "action" as const, + subject_id: "77777777-7777-4777-8777-777777777777", + resolved_at: null, + resolution_reason: null, + status: "read" as const, + version: 2, + created_at: "2026-09-24T00:00:00Z", + read_at: "2026-09-24T00:01:00Z", + read_by: "principal:11111111-1111-4111-8111-111111111111" + }; + + expect(selectHeaderNotifications([notification])).toEqual([notification]); + expect(selectHeaderNotifications([{ + ...notification, + lifecycle_status: "resolved", + resolved_at: "2026-09-24T00:02:00Z", + resolution_reason: "action_approved" + }])).toEqual([]); + }); +}); + function event( eventSequence: number, taskId: string, diff --git a/src/features/workbench/hooks/use-operational-tasks.ts b/src/features/workbench/hooks/use-operational-tasks.ts index e834a5d..6a3c9ad 100644 --- a/src/features/workbench/hooks/use-operational-tasks.ts +++ b/src/features/workbench/hooks/use-operational-tasks.ts @@ -59,6 +59,10 @@ export function collectOperationalTaskEventChanges(events: WorkbenchEvent[]) { return { taskIds, deletedTaskIds, notificationsChanged }; } +export function selectHeaderNotifications(items: OperationalTaskNotification[]) { + return items.filter((item) => item.lifecycle_status === "active"); +} + export function useOperationalTasks( getAccessToken: AccessTokenProvider | undefined, sync: WorkbenchSyncState @@ -106,9 +110,7 @@ export function useOperationalTasks( forceRefreshRef.current = false; detailResetGenerationRef.current = sync.detailResetGeneration; setTasks(nextTasks); - setNotifications((sync.snapshot?.notifications ?? []).filter( - (item) => item.lifecycle_status === "active" && item.status === "unread" - )); + setNotifications(selectHeaderNotifications(sync.snapshot?.notifications ?? [])); setAvailable(true); activatedRef.current = true; setError(null); @@ -283,13 +285,15 @@ export function useOperationalTasks( }, [getAccessToken, refresh]); const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => { - await acknowledgeOperationalTaskNotification( + const updated = await acknowledgeOperationalTaskNotification( notificationId, version, createOperationalRequestId(), getAccessToken ); - setNotifications((current) => current.filter((item) => item.notification_id !== notificationId)); + setNotifications((current) => selectHeaderNotifications(current.map((item) => + item.notification_id === notificationId ? updated : item + ))); }, [getAccessToken]); const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) => diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index de46133..15155f7 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -766,7 +766,9 @@ export function MapWorkbenchPage({ if (alert.id.startsWith("operational-task-notification:")) { const notificationId = alert.id.slice("operational-task-notification:".length); const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId); - if (notification) void operationalTasks.acknowledgeNotification(notificationId, notification.version); + if (notification?.status === "unread") { + void operationalTasks.acknowledgeNotification(notificationId, notification.version); + } } const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id); if (conditionId) {