refactor(workbench): remove redundant notification state

This commit is contained in:
2026-09-24 17:11:15 +08:00
parent 66dcb97e1c
commit f2438e4660
5 changed files with 41 additions and 76 deletions
@@ -71,11 +71,8 @@ export function useOperationalTasks(
const acknowledgeEventsThrough = sync.acknowledgeEventsThrough; const acknowledgeEventsThrough = sync.acknowledgeEventsThrough;
const [tasks, setTasks] = useState<OperationalTask[]>([]); const [tasks, setTasks] = useState<OperationalTask[]>([]);
const [notifications, setNotifications] = useState<OperationalTaskNotification[]>([]); const [notifications, setNotifications] = useState<OperationalTaskNotification[]>([]);
const [available, setAvailable] = useState(false);
const activatedRef = useRef(false);
const tasksRef = useRef<OperationalTask[]>([]); const tasksRef = useRef<OperationalTask[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0); const [refreshGeneration, setRefreshGeneration] = useState(0);
const forceRefreshRef = useRef(false); const forceRefreshRef = useRef(false);
@@ -111,13 +108,9 @@ export function useOperationalTasks(
detailResetGenerationRef.current = sync.detailResetGeneration; detailResetGenerationRef.current = sync.detailResetGeneration;
setTasks(nextTasks); setTasks(nextTasks);
setNotifications(selectHeaderNotifications(sync.snapshot?.notifications ?? [])); setNotifications(selectHeaderNotifications(sync.snapshot?.notifications ?? []));
setAvailable(true);
activatedRef.current = true;
setError(null); setError(null);
setLastSyncedAt(new Date().toISOString());
} catch (nextError) { } catch (nextError) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
if (!activatedRef.current) setAvailable(false);
setError(nextError instanceof Error ? nextError.message : "工况任务服务暂时不可用。"); setError(nextError instanceof Error ? nextError.message : "工况任务服务暂时不可用。");
} finally { } finally {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -159,7 +152,6 @@ export function useOperationalTasks(
...tasksRef.current.filter((task) => !changed.has(task.task_id)) ...tasksRef.current.filter((task) => !changed.has(task.task_id))
]; ];
setTasks(tasksRef.current); setTasks(tasksRef.current);
setLastSyncedAt(new Date().toISOString());
} }
acknowledgeEventsThrough(lastSequence); acknowledgeEventsThrough(lastSequence);
}).catch((nextError) => { }).catch((nextError) => {
@@ -189,11 +181,8 @@ export function useOperationalTasks(
}, getAccessToken); }, getAccessToken);
tasksRef.current = [task, ...tasksRef.current.filter((item) => item.task_id !== task.task_id)]; tasksRef.current = [task, ...tasksRef.current.filter((item) => item.task_id !== task.task_id)];
setTasks(tasksRef.current); setTasks(tasksRef.current);
activatedRef.current = true;
setAvailable(true);
setLoading(false); setLoading(false);
setError(null); setError(null);
setLastSyncedAt(new Date().toISOString());
return task; return task;
}, [getAccessToken]); }, [getAccessToken]);
@@ -300,10 +289,7 @@ export function useOperationalTasks(
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]); fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
return { return {
available,
error: error ?? sync.error, error: error ?? sync.error,
syncStatus: sync.status,
lastSyncedAt,
loading, loading,
tasks, tasks,
conditions, conditions,
+14 -11
View File
@@ -107,10 +107,15 @@ import type {
WorkbenchUser WorkbenchUser
} from "./types"; } from "./types";
import { getScheduledConditionIdFromAlertId } from "./utils/scheduled-condition-alerts"; import { getScheduledConditionIdFromAlertId } from "./utils/scheduled-condition-alerts";
import { getOperationalNotificationRoute } from "./utils/operational-notification-routing"; import { getOperationalNotificationSeverity } from "./utils/operational-notification-routing";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts"; import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables(); const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
const ALERT_TIME_FORMATTER = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
});
export function MapWorkbenchPage({ export function MapWorkbenchPage({
user = WORKBENCH_USER, user = WORKBENCH_USER,
@@ -212,28 +217,26 @@ export function MapWorkbenchPage({
}); });
const scheduledConditions = useMemo(() => [ const scheduledConditions = useMemo(() => [
...runtimeScheduledConditions, ...runtimeScheduledConditions,
...(operationalTasks.available ...operationalTasks.conditions.filter((condition) =>
? operationalTasks.conditions.filter((condition) =>
condition.operationalTask?.task_type === "event_investigation" condition.operationalTask?.task_type === "event_investigation"
|| condition.operationalTask?.task_type === "external_work_order" || condition.operationalTask?.task_type === "external_work_order"
) )
: []) ], [operationalTasks.conditions, runtimeScheduledConditions]);
], [operationalTasks.available, operationalTasks.conditions, runtimeScheduledConditions]);
const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => { const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
const taskNotifications = operationalTasks.notifications.flatMap((notification) => { const taskNotifications = operationalTasks.notifications.flatMap((notification) => {
const route = getOperationalNotificationRoute(notification.kind); const severity = getOperationalNotificationSeverity(notification.kind);
return route ? [{ return severity ? [{
id: `operational-task-notification:${notification.notification_id}`, id: `operational-task-notification:${notification.notification_id}`,
conditionId: operationalTaskConditionId(notification.task_id), conditionId: operationalTaskConditionId(notification.task_id),
title: notification.title, title: notification.title,
description: notification.description, description: notification.description,
severity: route.severity, severity,
occurredAt: notification.created_at, occurredAt: notification.created_at,
time: new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(notification.created_at)) time: ALERT_TIME_FORMATTER.format(new Date(notification.created_at))
}] : []; }] : [];
}); });
return operationalTasks.available ? [...taskNotifications, ...runtimeWorkbenchAlerts] : runtimeWorkbenchAlerts; return [...taskNotifications, ...runtimeWorkbenchAlerts];
}, [operationalTasks.available, operationalTasks.notifications, runtimeWorkbenchAlerts]); }, [operationalTasks.notifications, runtimeWorkbenchAlerts]);
const collapseAgentPanel = agent.collapsePanel; const collapseAgentPanel = agent.collapsePanel;
const expandAgentPanel = agent.expandPanel; const expandAgentPanel = agent.expandPanel;
const clearActiveTool = useCallback(() => { const clearActiveTool = useCallback(() => {
@@ -1,31 +1,22 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getOperationalNotificationRoute } from "./operational-notification-routing"; import { getOperationalNotificationSeverity } from "./operational-notification-routing";
describe("getOperationalNotificationRoute", () => { describe("getOperationalNotificationSeverity", () => {
it.each(["task_created", "report_updated", "task_completed"] as const)( it.each(["task_created", "report_updated", "task_completed"] as const)(
"keeps normal lifecycle notification %s out of exception handling", "keeps normal lifecycle notification %s out of exception handling",
(kind) => expect(getOperationalNotificationRoute(kind)).toBeNull() (kind) => expect(getOperationalNotificationSeverity(kind)).toBeNull()
); );
it("routes failures, approvals, human input, and external coordination", () => { it("maps actionable notifications to their displayed severity", () => {
expect(getOperationalNotificationRoute("task_failed")).toEqual({ expect(getOperationalNotificationSeverity("task_failed")).toBe("critical");
category: "task_exception", expect(getOperationalNotificationSeverity("run_delivery_degraded")).toBe("critical");
severity: "critical" expect(getOperationalNotificationSeverity("access_approval_pending")).toBe("warning");
}); expect(getOperationalNotificationSeverity("action_approval_pending")).toBe("warning");
expect(getOperationalNotificationRoute("access_approval_pending")?.category) expect(getOperationalNotificationSeverity("human_input_required")).toBe("warning");
.toBe("human_approval"); expect(getOperationalNotificationSeverity("external_coordination_failed")).toBe("critical");
expect(getOperationalNotificationRoute("action_approval_pending")?.category)
.toBe("human_approval");
expect(getOperationalNotificationRoute("human_input_required")?.category)
.toBe("human_input");
expect(getOperationalNotificationRoute("external_coordination_failed")).toEqual({
category: "external_coordination",
severity: "critical"
});
}); });
it("keeps legacy action notifications visible during the database rollout", () => { it("keeps legacy action notifications visible during the database rollout", () => {
expect(getOperationalNotificationRoute("action_pending")?.category) expect(getOperationalNotificationSeverity("action_pending")).toBe("warning");
.toBe("human_approval");
}); });
}); });
@@ -1,33 +1,19 @@
import type { OperationalTaskNotification } from "../api/operational-task-client"; import type { OperationalTaskNotification } from "../api/operational-task-client";
export type OperationalNotificationCategory = const NOTIFICATION_SEVERITIES: Partial<
| "task_exception" Record<OperationalTaskNotification["kind"], "warning" | "critical">
| "human_approval"
| "human_input"
| "external_coordination";
export type OperationalNotificationRoute = {
category: OperationalNotificationCategory;
severity: "warning" | "critical";
};
const NOTIFICATION_ROUTES: Partial<
Record<OperationalTaskNotification["kind"], OperationalNotificationRoute>
> = { > = {
task_failed: { category: "task_exception", severity: "critical" }, task_failed: "critical",
run_delivery_degraded: { category: "task_exception", severity: "critical" }, run_delivery_degraded: "critical",
action_pending: { category: "human_approval", severity: "warning" }, action_pending: "warning",
access_approval_pending: { category: "human_approval", severity: "warning" }, access_approval_pending: "warning",
action_approval_pending: { category: "human_approval", severity: "warning" }, action_approval_pending: "warning",
human_input_required: { category: "human_input", severity: "warning" }, human_input_required: "warning",
external_coordination_failed: { external_coordination_failed: "critical"
category: "external_coordination",
severity: "critical"
}
}; };
export function getOperationalNotificationRoute( export function getOperationalNotificationSeverity(
kind: OperationalTaskNotification["kind"] kind: OperationalTaskNotification["kind"]
): OperationalNotificationRoute | null { ): "warning" | "critical" | null {
return NOTIFICATION_ROUTES[kind] ?? null; return NOTIFICATION_SEVERITIES[kind] ?? null;
} }
@@ -428,8 +428,7 @@ test("后端没有调查任务时显示空态,分类标签按压不缩放", as
await page.goto("/"); await page.goto("/");
await page.getByRole("button", { name: "工况任务", exact: true }).press("Enter"); await page.getByRole("button", { name: "工况任务", exact: true }).press("Enter");
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
const expandButton = conditionPanel.getByRole("button", { name: "展开工况任务" }); await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
if (await expandButton.isVisible()) await expandButton.click();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click(); await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(conditionPanel.getByText("暂无任务", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("暂无任务", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("新任务进入当前分类后会出现在这里。", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("新任务进入当前分类后会出现在这里。", { exact: true })).toBeVisible();