feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -1,66 +1,77 @@
import { z } from "zod";
import type { FeatureCollection, Geometry } from "geojson";
import { parseFrontendChart } from "@/features/agent";
import { investigationMapTargetSchema } from "./investigation-report";
import {
presentationCommandSchema,
presentationOverlayDataSchema,
type PresentationCommand
} from "@/features/agent";
const id = z.string().trim().min(1).max(128);
const chartSchema = z.custom<NonNullable<ReturnType<typeof parseFrontendChart>>>((value) => parseFrontendChart(value) !== null);
const position = z.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]).rest(z.number().finite()).refine((value) => value.length <= 3);
const line = z.array(position).min(2).max(20000);
const ring = z.array(position).min(4).max(20000).refine((value) => value[0][0] === value.at(-1)![0] && value[0][1] === value.at(-1)![1]);
const geometrySchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("Point"), coordinates: position }).strict(),
z.object({ type: z.literal("MultiPoint"), coordinates: z.array(position).min(1).max(20000) }).strict(),
z.object({ type: z.literal("LineString"), coordinates: line }).strict(),
z.object({ type: z.literal("MultiLineString"), coordinates: z.array(line).min(1).max(20000) }).strict(),
z.object({ type: z.literal("Polygon"), coordinates: z.array(ring).min(1).max(20000) }).strict(),
z.object({ type: z.literal("MultiPolygon"), coordinates: z.array(z.array(ring).min(1).max(20000)).min(1).max(20000) }).strict()
]).refine((geometry) => {
const count = (coordinates: unknown): number => !Array.isArray(coordinates) ? 0
: typeof coordinates[0] === "number" ? 1 : coordinates.reduce<number>((total, item) => total + count(item), 0);
return count(geometry.coordinates) <= 20000;
});
export const investigationOverlayDataSchema = z.object({
type: z.literal("FeatureCollection"),
features: z.array(z.object({
type: z.literal("Feature"),
id: z.union([z.string(), z.number().finite()]).optional(),
properties: z.record(z.string(), z.unknown()).nullable(),
geometry: geometrySchema
}).strict()).min(1).max(1000)
}).strict();
const overlaySchema = z.object({
action: z.literal("set_overlay"), overlay_id: id, title: z.string().trim().min(1).max(200),
mode: z.enum(["geometry", "heatmap"]), evidence_id: z.string().trim().min(1), data: investigationOverlayDataSchema
}).strict();
const commandSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("highlight_features"), layer: investigationMapTargetSchema.shape.source_id,
feature_ids: z.array(id).min(1).max(100), effect: z.enum(["steady", "pulse"]) }).strict(),
z.object({ action: z.literal("clear_highlights") }).strict(),
z.object({ action: z.literal("open_chart"), chart_id: id, chart: chartSchema, evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100) }).strict(),
z.object({ action: z.literal("close_chart"), chart_id: id }).strict(),
overlaySchema,
z.object({ action: z.literal("clear_overlay"), overlay_id: id }).strict()
]).superRefine((command, ctx) => {
if (command.action !== "set_overlay" || command.mode !== "heatmap") return;
if (command.data.features.some((feature) => feature.geometry.type !== "Point"
|| (feature.properties?.weight !== undefined && (typeof feature.properties.weight !== "number" || !Number.isFinite(feature.properties.weight) || feature.properties.weight < 0)))) {
ctx.addIssue({ code: "custom", message: "投诉热力只接受点和有限非负权重" });
}
});
export const investigationPresentationEventSchema = z.object({
task_id: z.string().uuid(), run_id: z.string().uuid(), execution_generation: z.number().int().positive(), command: commandSchema
});
export const investigationPresentationCursorSchema = z.object({
task_id: z.string().uuid(), execution_generation: z.number().int().positive(),
active_run: z.object({ run_id: z.string().uuid(), generation: z.number().int().positive(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]) }).strict().nullable(),
event_cursor: z.number().int().nonnegative()
}).strict();
export type InvestigationPresentationCommand = z.infer<typeof commandSchema>;
export const investigationOverlayDataSchema = presentationOverlayDataSchema;
export const investigationPresentationEventSchema = z
.object({
task_id: z.string().uuid(),
run_id: z.string().uuid(),
execution_generation: z.number().int().positive(),
command: presentationCommandSchema.refine(
(command) => command.action !== "open_chart" || command.evidence_refs.length > 0,
"task charts require evidence references"
)
})
.strict();
export const investigationPresentationCursorSchema = z
.object({
task_id: z.string().uuid(),
execution_generation: z.number().int().positive(),
active_run: z
.object({
run_id: z.string().uuid(),
generation: z.number().int().positive(),
status: z.enum([
"queued",
"claimed",
"running",
"succeeded",
"failed",
"timed_out",
"cancelled"
])
})
.strict()
.nullable(),
event_cursor: z.number().int().nonnegative()
})
.strict();
export type InvestigationPresentationCommand = PresentationCommand;
export type InvestigationPresentationEvent = z.infer<typeof investigationPresentationEventSchema>;
export type InvestigationPresentationCursor = z.infer<typeof investigationPresentationCursorSchema>;
export type InvestigationOverlay = Extract<InvestigationPresentationCommand, { action: "set_overlay" }> & { visible: boolean };
export type InvestigationOverlay = Extract<
InvestigationPresentationCommand,
{ action: "set_overlay" }
> & { visible: boolean };
export type InvestigationOverlayData = FeatureCollection<Geometry>;
export const investigationPresentationHistoryPageSchema = z
.object({
task_id: z.string().uuid(),
event_cursor: z.number().int().nonnegative(),
items: z
.array(
z
.object({
event_seq: z.number().int().positive(),
run_id: z.string().uuid(),
execution_generation: z.number().int().positive(),
occurred_at: z.string().datetime({ offset: true }),
command: z.unknown()
})
.strict()
)
.max(200),
next_after: z.number().int().positive().nullable()
})
.strict();
export type InvestigationPresentationHistoryEvent = z.infer<
typeof investigationPresentationHistoryPageSchema
>["items"][number] & { task_id: string };
export const presentationEventKey = (taskId: string, sequence: number) =>
`task:${taskId}:event:${sequence}`;
@@ -59,6 +59,15 @@ describe("investigation report read contract", () => {
expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]);
expect(value.data_gaps?.[0]?.node_id).toBeNull();
});
it("distinguishes waiting for dependencies from human feedback without inventing missing states", () => {
const value = investigationReportSchema.parse({ ...base, diagram: {
title: "等待结果", mermaid: "flowchart LR\nA[外部回执] --> B[人工复核] --> C[下一步]",
node_refs: [{ node_id: "A", label: "外部回执", status: "waiting" },
{ node_id: "B", label: "人工复核", status: "waiting_human" },
{ node_id: "C", label: "下一步" }]
} });
expect(value.diagram?.node_refs.map((node) => node.status)).toEqual(["waiting", "waiting_human", undefined]);
});
it.each([
{ outcome: "other" },
{ stage: "interim" },
@@ -34,7 +34,7 @@ export const investigationDiagramSchema = z
.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
status: z.enum(["pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
map_targets: z.array(investigationMapTargetSchema).max(100).optional(),
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([])
@@ -1,9 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskPresentationHistory,
fetchOperationalTaskNotifications,
fetchOperationalTaskRuns,
parseOperationalTask,
parseOperationalTaskSummary,
parseOperationalTaskNotification
} from "./operational-task-client";
@@ -72,6 +74,36 @@ describe("operational task contract", () => {
expect(url.searchParams.get("offset")).toBe("0");
});
it("accepts the Agent instance owner emitted for task runs", () => {
const {
latest_plan: _latestPlan,
latest_report: _latestReport,
evidence: _evidence,
actions: _actions,
permissions: _permissions,
...summary
} = task();
const run = {
run_id: id, task_id: id, generation: 2, execution_request_id: id,
status: "running", revision: 1, agent_session_id: "session",
agent_instance_id: "supply-agent-1", lease_expires_at: time, heartbeat_at: time,
started_at: time, finished_at: null, failure_code: null, failure_detail: null,
dispatch_attempts: 1, next_dispatch_at: time, created_at: time, updated_at: time
};
expect(parseOperationalTaskSummary({ ...summary, active_run: run }).active_run)
.toHaveProperty("agent_instance_id", "supply-agent-1");
});
it("accepts only system-data work order provenance", () => {
const snapshot = {
work_order_id: id, revision: 1, title: "投诉工单", summary: "待核查", status: "open",
priority: "normal", assignee: null, scheduled_at: null, work_order_type: "water_quality_complaint",
location: "供水小区", scenario_code: null, origin: "system_data", updated_at: time
};
expect(parseOperationalTask({ ...task(), work_order_snapshot: snapshot }).work_order_snapshot?.origin).toBe("system_data");
expect(() => parseOperationalTask({ ...task(), work_order_snapshot: { ...snapshot, origin: "synthetic" } })).toThrow();
});
it("accepts execution generation on both plan and report", () => {
const parsed = parseOperationalTask(task());
expect(parsed.latest_plan).toHaveProperty("execution_generation", 2);
@@ -190,3 +222,22 @@ it("reads persisted MCP evidence without expanding task access-request scopes",
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...snapshot, source: "arbitrary_tool" })));
await expect(fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).rejects.toThrow();
});
it("reads all presentation pages at one boundary and rejects mismatched or stalled cursors", async () => {
const record = (event_seq: number) => ({ event_seq, run_id: id, execution_generation: 1, occurred_at: time, command: { action: "clear_highlights" } });
const fetch = vi.fn().mockResolvedValueOnce(Response.json({ task_id: id, event_cursor: 30, items: [record(7)], next_after: 7 }))
.mockResolvedValueOnce(Response.json({ task_id: id, event_cursor: 30, items: [record(20)], next_after: null }));
vi.stubGlobal("fetch", fetch);
expect((await fetchOperationalTaskPresentationHistory(id, 30)).map((item) => item.event_seq)).toEqual([7, 20]);
expect(fetch.mock.calls.map(([url]) => new URL(url).searchParams.get("after"))).toEqual(["0", "7"]);
expect(fetch.mock.calls.every(([url, init]) => new URL(url).searchParams.get("through") === "30" && !init.body)).toBe(true);
for (const page of [
{ task_id: id, event_cursor: 29, items: [], next_after: null },
{ task_id: id, event_cursor: 30, items: [], next_after: 7 },
{ task_id: "50000000-0000-4000-8000-000000000001", event_cursor: 30, items: [], next_after: null }
]) {
vi.stubGlobal("fetch", vi.fn(async () => Response.json(page)));
await expect(fetchOperationalTaskPresentationHistory(id, 30)).rejects.toThrow();
}
});
@@ -1,6 +1,6 @@
import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema } from "./investigation-presentation";
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -191,6 +191,7 @@ const taskPermissionsSchema = z.object({
can_execute_work_orders: z.boolean(),
can_decide_commands: z.boolean(),
can_execute_commands: z.boolean(),
can_answer_questions: z.boolean().optional(),
can_decide_access: z.boolean().default(false)
}).strict();
const externalWorkOrderActivitySchema = z.object({
@@ -221,9 +222,25 @@ const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
scenario_code: z.string().min(1).nullable(),
feedback_requirements: z.array(z.string().min(1)).default([]),
activity: z.array(externalWorkOrderActivitySchema).default([]),
origin: z.literal("synthetic"),
origin: z.literal("system_data"),
updated_at: workbenchDateTimeSchema
}).strict());
const taskQuestionAnswerSchema = z.object({ question_id: z.string().min(1).max(128),
selected_option_ids: z.array(z.string().min(1).max(128)).max(12), custom_text: z.string().max(2000) }).strict();
const taskQuestionRequestSchema = z.object({
question_request_id: z.string().uuid(), task_id: z.string().uuid(), run_id: z.string().uuid(),
execution_generation: z.number().int().positive(), source_session_id: z.string(),
status: z.enum(["pending", "answered", "cancelled"]), version: z.number().int().positive(),
created_at: workbenchDateTimeSchema, answered_at: workbenchDateTimeSchema.nullable(), answered_by: z.string().nullable(),
questions: z.array(z.object({ question_id: z.string().min(1).max(128), title: z.string().min(1).max(200), body: z.string().min(1).max(2000),
options: z.array(z.object({ id: z.string().min(1).max(128), label: z.string().min(1).max(200) }).strict()).max(12),
mode: z.enum(["single", "multiple"]), allow_custom: z.boolean(), required: z.boolean() }).strict()).min(1).max(3),
answers: z.array(taskQuestionAnswerSchema).max(3)
}).strict();
export type TaskQuestionRequest = z.infer<typeof taskQuestionRequestSchema>;
export type TaskQuestionAnswer = z.infer<typeof taskQuestionAnswerSchema>;
export type AnswerTaskQuestion = (questionId: string, version: number, answers: TaskQuestionAnswer[], requestId: string) => Promise<unknown>;
const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(),
@@ -231,6 +248,7 @@ const operationalTaskSchema = taskSummarySchema.extend({
evidence: z.array(evidenceSchema),
actions: z.array(actionSchema),
access_requests: z.array(accessRequestSchema).default([]),
questions: z.array(taskQuestionRequestSchema).optional(),
permissions: taskPermissionsSchema,
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
}).strict();
@@ -252,7 +270,7 @@ const notificationSchema = z.object({
title: z.string().min(1),
description: z.string().min(1),
lifecycle_status: z.enum(["active", "resolved"]).default("active"),
subject_type: z.enum(["task", "run", "access_request", "action"]).default("task"),
subject_type: z.enum(["task", "run", "access_request", "action", "question"]).default("task"),
subject_id: z.string().uuid().nullable().default(null),
report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(),
resolved_at: workbenchDateTimeSchema.nullable().default(null),
@@ -451,6 +469,39 @@ export async function fetchOperationalTaskPresentationCursor(taskId: string, get
));
}
export async function fetchOperationalTaskPresentationHistory(
taskId: string,
through: number,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
) {
const items: InvestigationPresentationHistoryEvent[] = [];
let after = 0;
while (true) {
signal?.throwIfAborted();
const page = investigationPresentationHistoryPageSchema.parse(
await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-events?after=${after}&through=${through}&limit=100`,
getAccessToken,
{ signal }
)
);
if (page.task_id !== taskId || page.event_cursor !== through)
throw new Error("调查展示历史与所选任务或游标不一致");
let previous = after;
for (const item of page.items) {
if (item.event_seq <= previous || item.event_seq > through)
throw new Error("调查展示历史顺序不合法");
previous = item.event_seq;
items.push({ ...item, task_id: taskId });
}
if (page.next_after === null) return items;
if (page.next_after !== previous || previous <= after)
throw new Error("调查展示历史分页没有前进");
after = previous;
}
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: WorkbenchAccess) {
return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks",
@@ -572,3 +623,13 @@ export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evide
export function createOperationalRequestId() {
return crypto.randomUUID();
}
export async function answerOperationalTaskQuestion(taskId: string, questionId: string,
version: number, answers: TaskQuestionAnswer[], requestId: string, getAccessToken?: WorkbenchAccess) {
return operationalTaskSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/questions/${encodeURIComponent(questionId)}/answer`,
getAccessToken, { method: "POST", body: JSON.stringify({ request_id: requestId,
expected_version: version, answers: z.array(taskQuestionAnswerSchema).max(3).parse(answers) }) }
));
}
@@ -130,6 +130,35 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
return { ...props, rerender };
}
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
run_id: "70000000-0000-4000-8000-000000000002", source_session_id: "s", execution_generation: taskBase.execution_generation,
status: "pending", version: 1, created_at: taskBase.created_at, answered_at: null, answered_by: null, answers: [],
questions: [{ question_id: "sampling", title: "是否有采样记录", body: "请补充采样信息", options: [], mode: "single", allow_custom: true, required: true }] }],
latest_report: { ...taskBase.latest_report!, report: { ...taskBase.latest_report!.report,
data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段", impact: "待核验",
required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }] } }
};
const props = taskPanel(task);
const questions = screen.getByRole("region", { name: "调查提问" });
questions.scrollIntoView = vi.fn();
fireEvent.click(screen.getByRole("button", { name: "回答当前调查问题" }));
expect(questions).toHaveFocus();
expect(questions.scrollIntoView).toHaveBeenCalled();
expect(props.onContinue).not.toHaveBeenCalled();
});
it("labels free discussion separately when no fixed question is pending", () => {
const props = taskPanel({ ...taskBase, latest_report: { ...taskBase.latest_report!, report: {
...taskBase.latest_report!.report, data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段",
impact: "待核验", required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }]
} } });
expect(screen.queryByRole("button", { name: "补充事实" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "讨论此缺口" }));
expect(props.onContinue).toHaveBeenCalled();
});
it("references the selected report round instead of the latest task round, including legacy reports", () => {
const task = { ...taskBase, execution_generation: 2 };
const { rerender, ...props } = taskPanel(task);
@@ -191,7 +220,7 @@ it("opens the execution record bound to the selected report round", async () =>
fireEvent.click(await screen.findByRole("button", { name: /第 1 轮 · 报告第 1 版/ }));
expect(screen.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible();
expect(screen.queryByText("调查任务 · 第 2 轮", { exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "执行记录", hidden: true }));
fireEvent.click(screen.getByRole("button", { name: "执行记录" }));
await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({
executionGeneration: 1,
@@ -75,6 +75,7 @@ type AgentExecutionPlanPanelProps = {
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onRefresh: () => void;
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
@@ -94,11 +95,12 @@ export function AgentExecutionPlanPanel({
onLoadEvidenceSnapshot,
onRefresh,
onLoadReports,
onAnswerQuestion,
onReportViewed,
focusActionId
}: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
onAnswerQuestion={onAnswerQuestion} onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState } from "react";
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
import type { WorkbenchAccess } from "../api/request-context";
@@ -148,7 +149,7 @@ export function AnalysisHistorySection({
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
</span>
<span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]">
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
{item.runTypeLabel} · {formatLocalMonthDayTime(item.createdAt)} · {item.createdBy}
</span>
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
{item.hasResults ? "已有持久化结果" : "仅有运行记录"}
@@ -187,15 +188,7 @@ function statusTone(status: string): StatusTone {
return "neutral";
}
function formatHistoryTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "分析成果历史请求失败";
@@ -1,100 +0,0 @@
import { Target, TriangleAlert, X } from "lucide-react";
import { useMemo } from "react";
import type { DetailFeature } from "../types";
import {
getFeatureInsightMetrics,
getLocalizedFeatureProperties
} from "../utils/feature-properties";
import { FeaturePropertyList } from "./feature-property-list";
type FeatureInsightPanelProps = {
feature: DetailFeature | null;
onClose: () => void;
};
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
const metrics = useMemo(() => {
if (!feature) {
return [];
}
return getFeatureInsightMetrics(feature.layer, feature.properties);
}, [feature]);
const propertyEntries = useMemo(() => {
if (!feature) {
return [];
}
return getLocalizedFeatureProperties(feature.properties);
}, [feature]);
return (
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
<div className="border-b border-[var(--surface-divider)]/80 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium tracking-[0.14em] text-[var(--ink-muted)]">要素详情</p>
<h2 className="mt-1 text-lg font-semibold text-slate-950">
{feature ? feature.title : "选择一个管网对象"}
</h2>
</div>
<button
type="button"
aria-label="关闭详情"
title="关闭详情"
onClick={onClose}
className="surface-control grid h-9 w-9 place-items-center rounded border text-[var(--ink-muted)] hover:text-slate-900"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className="mt-2 text-sm text-slate-600">
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性和 Agent 解释。"}
</p>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{feature ? (
<>
<div className="grid grid-cols-2 gap-2">
{metrics.map(({ label, value }) => (
<div key={label} className="surface-reading rounded border p-3">
<p className="text-xs text-[var(--ink-muted)]">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
</div>
))}
</div>
<section className="material-tone-agent mt-4 rounded border border-[var(--surface-border)] p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--action-blue-pressed)]">
<TriangleAlert size={16} aria-hidden="true" />
Agent 解释
</div>
<p className="mt-2 text-sm leading-6 text-slate-700">
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
</p>
</section>
<section className="mt-4">
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
<div className="mt-2">
<FeaturePropertyList entries={propertyEntries} />
</div>
</section>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-[var(--ink-muted)]">
<Target size={22} aria-hidden="true" />
</div>
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
<p className="mt-1 max-w-[260px] text-sm leading-6 text-[var(--ink-muted)]">
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
</p>
</div>
)}
</div>
</aside>
);
}
@@ -1,24 +0,0 @@
type IconButtonProps = {
label: string;
children: React.ReactNode;
onClick?: () => void;
active?: boolean;
};
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`grid h-10 w-10 place-items-center rounded border transition ${
active
? "border-slate-800 bg-slate-900 text-white"
: "surface-control border-transparent text-slate-700 hover:bg-[var(--surface-reading)]"
}`}
>
{children}
</button>
);
}
@@ -0,0 +1,123 @@
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup";
import { createInvestigationDevCase } from "../utils/investigation-dev-cases";
import { InvestigationDevTools } from "./investigation-dev-tools";
afterEach(cleanup);
describe("investigation Dev case selection", () => {
it("previews a loaded case in one click without an investigation or separate validation step", async () => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const cases = screen.getByRole("combobox", { name: "JSONL 测试案例" });
expect(cases).toBeEnabled();
await user.click(cases);
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
"DEMO-PIPE-01"
);
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
expect(onPreview).toHaveBeenCalledWith(
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
"候选管段对比"
);
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
expect(cases).toHaveTextContent("候选管段对比");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("previews data without a map but waits before executing map commands", async () => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady={false}
charts={0}
overlay={null}
activity={[]}
onSample={vi.fn()}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
await user.clear(input);
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
expect(execute).toBeEnabled();
await user.click(execute);
expect(screen.getByRole("alert")).toHaveTextContent("地图尚未就绪");
expect(onPreview).toHaveBeenCalledTimes(1);
});
it.each(["invalid_json", "invalid_command", "invalid_chart"] as const)(
"validates %s on execute and never runs a partial batch",
async (id) => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(screen.getByRole("alert")).toHaveTextContent(
id === "invalid_json" ? "第 2 行" : "第 1 行"
);
expect(onPreview).not.toHaveBeenCalled();
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
await user.clear(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
}
);
});
@@ -0,0 +1,410 @@
import { useId, useMemo, useRef, useState } from "react";
import { Check, Download, FlaskConical, Play, RotateCcw } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import {
investigationDevCases,
type InvestigationDevCaseId
} from "../utils/investigation-dev-cases";
import type { OperationalTask } from "../api/operational-task-client";
import {
investigationActionLabels,
investigationReportJsonl,
investigationReportUiSummary,
parseInvestigationDevJsonl,
type InvestigationDevPreview,
type InvestigationPresentationActivity
} from "../utils/investigation-dev-preview";
export type InvestigationDevToolsProps = {
task: OperationalTask | null;
mapReady: boolean;
charts: number;
overlay: string | null;
activity: InvestigationPresentationActivity[];
onSample: (caseId?: InvestigationDevCaseId) => string;
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
onClear: () => void;
};
export function InvestigationDevTools({
task,
mapReady,
charts,
overlay,
activity,
onSample,
onPreview,
onClear
}: InvestigationDevToolsProps) {
const [open, setOpen] = useState(true);
const [input, setInput] = useState("");
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(0);
const previewActive = useRef(false);
const inputId = useId();
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
function replaceInput(value: string, nextCaseId?: InvestigationDevCaseId) {
setInput(value);
setCaseId(nextCaseId);
setChecked(null);
setPreview(null);
setMessage(null);
setError(null);
setSelectedRecord(0);
}
function clearLoadedPreview() {
if (!previewActive.current) return;
onClear();
previewActive.current = false;
}
function loadCase(nextCaseId: InvestigationDevCaseId) {
if (running || !mapReady) return;
clearLoadedPreview();
replaceInput(onSample(nextCaseId), nextCaseId);
}
const selectedCase = investigationDevCases.find((item) => item.id === caseId);
function validate() {
try {
const parsed = parseInvestigationDevJsonl(input);
setChecked(parsed);
setError(null);
setMessage(
`已识别 ${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
);
return parsed;
} catch (cause) {
setChecked(null);
setError(cause instanceof Error ? cause.message : "JSONL 校验失败。");
setMessage(null);
return null;
}
}
async function run() {
if (running || !input.trim()) return;
const parsed = validate();
if (!parsed) return;
if (parsed.commands.length > 0 && !mapReady) {
setError("地图尚未就绪,请稍后再执行本地预览。");
setMessage(null);
return;
}
setRunning(true);
setError(null);
setPreview(parsed);
previewActive.current = true;
try {
await onPreview(parsed, selectedCase?.label ?? "JSONL");
setMessage(
`本地预览完成:${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError"))
setError(cause instanceof Error ? cause.message : "本地展示失败。");
} finally {
setRunning(false);
}
}
function download() {
if (!checked) return;
const url = URL.createObjectURL(
new Blob([`${input.trim()}\n`], { type: "application/x-ndjson;charset=utf-8" })
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `investigation-preview-${task?.execution_generation ?? "local"}.jsonl`;
anchor.click();
URL.revokeObjectURL(url);
}
return (
<div className="min-w-0 space-y-4" data-testid="investigation-dev-tools">
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-medium text-muted-foreground">当前调查</p>
<Button
type="button"
variant="ghost"
className="h-8 px-0 text-xs"
aria-expanded={open}
aria-controls="investigation-dev-lab"
onClick={() => setOpen(!open)}
>
<FlaskConical size={14} />
{open ? "收起动作测试" : "测试地图、图表与 JSONL"}
</Button>
</div>
<p className="break-words text-sm font-semibold text-foreground">
{task ? task.title : "尚未选择调查"}
</p>
<p className="text-muted-foreground">
{task
? `第 ${task.execution_generation} 轮 · 实时图表 ${charts} · 报告图表 ${summary.charts} · 表格 ${summary.tables} · 地图入口 ${summary.maps}`
: "可直接载入、校验和执行本地案例;选择调查后可观察实时展示。"}
</p>
{task?.latest_report ? (
<p className="text-muted-foreground">
已保存报告第 {task.latest_report.revision} 版 · 第{" "}
{task.latest_report.execution_generation ?? "未知"} 轮
{task.latest_report.execution_generation !== task.execution_generation
? ",上一轮内容"
: ""}
</p>
) : null}
{overlay ? <p className="break-words text-muted-foreground">覆盖层:{overlay}</p> : null}
{summary.invalid ? (
<p className="text-[var(--status-warning-foreground)]">
{summary.invalid} 个报告界面块校验未通过。
</p>
) : null}
{task && !task.latest_report ? (
<p className="text-muted-foreground">本轮尚无报告,实时联动只处理选中后的新指令。</p>
) : null}
</div>
{open ? (
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
<h3 className="text-xs font-semibold">01 准备数据</h3>
<p className="text-[11px] leading-5 text-muted-foreground">
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
</p>
<FieldSelect
label="JSONL 测试案例"
placeholder="选择案例,立即载入 JSONL"
value={caseId ?? ""}
onValueChange={(value) => {
const nextCase = investigationDevCases.find((item) => item.id === value);
if (nextCase) loadCase(nextCase.id);
}}
options={investigationDevCases.map((item) => ({ value: item.id, label: item.label }))}
disabled={!mapReady || running}
contentClassName="workbench-shadcn-surface"
/>
{!mapReady ? (
<p className="text-[11px] text-muted-foreground">地图正在初始化,就绪后可载入案例。</p>
) : null}
{selectedCase ? (
<p aria-label="案例预期结果" className="text-xs leading-5 text-muted-foreground">
预期:{selectedCase.expected}
</p>
) : null}
<p className="text-[11px] leading-5 text-muted-foreground">
切换即载入,执行时自动校验;已执行的本地预览会清除。
</p>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!mapReady || running}
onClick={() => loadCase(caseId ?? "combined")}
>
载入合成样例
</Button>
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!task || !summary.blocks || running}
onClick={() => {
if (task) {
clearLoadedPreview();
replaceInput(investigationReportJsonl(task));
}
}}
>
载入当前报告
</Button>
</div>
<div className="space-y-1.5 border-t border-border pt-4">
<h3 className="pb-2 text-xs font-semibold">02 校验与执行</h3>
<label htmlFor={inputId} className="text-xs font-medium">
调查展示 JSONL
</label>
<Textarea
id={inputId}
value={input}
disabled={running}
onChange={(event) => replaceInput(event.target.value)}
rows={5}
maxLength={200_000}
spellCheck={false}
className="min-h-28 resize-y font-mono text-[11px] leading-5"
placeholder="每行一条展示指令、界面块或带来源的调查数据"
/>
</div>
<Disclosure
label="JSONL 格式说明"
className="[&>summary]:min-h-8 [&>summary]:text-muted-foreground"
>
<p className="pb-2 text-[11px] leading-5 text-muted-foreground">
presentation 执行受控展示,ui_block 预览界面;candidate
等调查数据仅展示,不自动操作地图。
</p>
</Disclosure>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!input.trim() || running}
onClick={validate}
>
<Check size={14} />
校验 JSONL
</Button>
<Button
type="button"
className="text-xs"
disabled={!input.trim() || running}
onClick={() => void run()}
>
<Play size={14} />
{running ? "正在预览" : "执行本地预览"}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
点击执行会自动校验当前 JSONL,整批校验通过后才预览;也可单独校验。
</p>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">03 检查结果</h3>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="text-xs"
disabled={!checked || running}
onClick={download}
>
<Download size={14} />
下载测试 JSONL
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-xs"
onClick={() => {
onClear();
previewActive.current = false;
setPreview(null);
setMessage("已清除当前浏览器的临时展示。");
setError(null);
}}
>
<RotateCcw size={14} />
清除临时展示
</Button>
</div>
</div>
{message ? (
<p role="status" className="break-words text-xs leading-5 text-muted-foreground">
{message}
</p>
) : null}
{error ? (
<p
role="alert"
className="break-words text-xs leading-5 text-[var(--status-danger-foreground)]"
>
{error}
</p>
) : null}
{preview ? (
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
<p className="text-xs text-muted-foreground">
业务结果已在展示面板打开,可切换动作历史回看。
</p>
<Disclosure label="原始数据检查">
{preview.records.length ? (
<div className="max-h-48 overflow-auto">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 pr-3 font-medium">来源</th>
<th className="py-1 text-right font-medium">证据</th>
</tr>
</thead>
<tbody>
{preview.records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="py-1.5 pr-3">
<button
type="button"
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
aria-label={`查看第 ${index + 1} 条调查数据`}
aria-pressed={selectedRecord === index}
onClick={() => setSelectedRecord(index)}
>
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</button>
</td>
<td className="py-1.5 pr-3">{String(record.origin)}</td>
<td className="py-1.5 text-right tabular-nums">
{(record.source_evidence_refs as string[]).length}
</td>
</tr>
))}
</tbody>
</table>
<pre
aria-label="调查数据详情"
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
>
{JSON.stringify(preview.records[selectedRecord], null, 2)}
</pre>
</div>
) : null}
</Disclosure>
</section>
) : null}
</div>
) : null}
{activity.length ? (
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">最近展示动作</h3>
<ol
aria-label="动作识别记录"
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
>
{activity.slice(-3).map((item) => (
<li key={`${item.source}-${item.id}`} className="break-words">
<span className="text-muted-foreground">
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
</span>
{investigationActionLabels[item.action]} ·{" "}
{item.status === "completed"
? "已应用"
: item.status === "error"
? "未完成"
: "展示中"}
{item.message ? (
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
) : null}
</li>
))}
</ol>
</section>
) : null}
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { MessageResponse } from "@/shared/ai-elements/message";
import { Button } from "@/shared/ui/button";
@@ -9,6 +9,10 @@ import {
INVESTIGATION_DIAGRAM_FONT,
investigationDiagramConfig,
investigationDiagramPlugins,
investigationDiagramStates,
investigationDiagramNodeId as getNodeId,
investigationNodeState,
paintInvestigationDiagramStates,
styleInvestigationDiagram
} from "../utils/investigation-diagram-style";
@@ -41,33 +45,42 @@ function DiagramUnavailable() {
);
}
const getNodeId = (node: Element | null | undefined) =>
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
export function InvestigationDiagramView({
diagram,
renderDetails,
onAsk,
running = false,
onLocate
}: {
diagram: InvestigationDiagram;
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
running?: boolean;
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
}) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const diagramRoot = useRef<HTMLDivElement>(null);
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId);
const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node, running)))];
if (!displayedStates.length) displayedStates.push("unknown");
const [fontsReady, setFontsReady] = useState(
() => typeof document === "undefined" || !document.fonts
);
useLayoutEffect(() => {
const root = diagramRoot.current;
if (!root) return;
const paint = () => paintInvestigationDiagramStates(root, diagram, running);
paint();
// Mermaid renders asynchronously. Reapply the latest snapshot when it
// replaces the SVG; observing children avoids reacting to our own styles.
const observer = new MutationObserver(paint);
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}, [diagram, running]);
useEffect(() => {
if (!document.fonts) return;
let active = true;
void document.fonts
.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
.load(`500 18px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
.catch(() => undefined)
.then(() => {
if (active) setFontsReady(true);
@@ -82,6 +95,7 @@ export function InvestigationDiagramView({
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
</p>
<div
ref={diagramRoot}
className="investigation-diagram mt-3"
onPointerDownCapture={(event) => {
const node = (event.target as Element).closest?.(".node");
@@ -104,6 +118,7 @@ export function InvestigationDiagramView({
>
{fontsReady ? (
<MessageResponse
key={diagram.mermaid}
icons={diagramControlIcons}
plugins={investigationDiagramPlugins}
mermaid={{
@@ -118,36 +133,25 @@ export function InvestigationDiagramView({
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
)}
</div>
{running && diagram.node_refs.some((node) => node.status === "running") ? (
<p aria-label="图示执行状态" className="mt-2 text-xs text-[var(--action-blue)]">蓝色:正在执行</p>
) : null}
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (
<div
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
aria-label="图示颜色说明"
>
{diagram.node_refs.some(
(node) => node.evidence_refs.length && !node.action_refs.length
) ? (
<span className="inline-flex items-center gap-1.5">
<div
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
aria-label="图示状态说明"
>
{displayedStates.map((key) => {
const state = investigationDiagramStates[key];
return (
<span key={key} className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-2 rounded-sm border border-[#6B9E8B] bg-[#EDF7F4]"
className="h-2.5 w-3.5 rounded-sm border"
style={{ backgroundColor: state.fill, borderColor: state.stroke,
borderStyle: state.dash === "0" ? "solid" : "dashed" }}
/>
引用证据
{state.label}
</span>
) : null}
{diagram.node_refs.some((node) => node.action_refs.length) ? (
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-2 rounded-sm border border-[#C4A168] bg-[#FFF6E8]"
/>
关联业务动作
</span>
) : null}
</div>
) : null}
);
})}
</div>
{diagram.node_refs.length > 0 ? (
<div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5">
{diagram.node_refs.map((node) => (
@@ -161,6 +165,7 @@ export function InvestigationDiagramView({
}
>
{node.label}
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node, running)].label}</span>
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
</button>
))}
@@ -172,16 +177,9 @@ export function InvestigationDiagramView({
aria-label={`节点详情 ${selected.label}`}
>
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
{selected.status ? <p className="text-xs text-[var(--ink-muted)]">{!running && selected.status === "running" ? "记录时执行中" : {
pending: "尚未开始", running: "正在执行", completed: "已完成", waiting_human: "等待人工反馈", failed: "执行失败", skipped: "已跳过", cancelled: "已取消"
}[selected.status]}</p> : null}
<p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected, running)].label}</p>
{renderDetails(selected)}
{onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null}
{onAsk ? (
<Button size="sm" variant="outline" onClick={() => { close(false); onAsk(selected); }}>
围绕此节点提问
</Button>
) : null}
</div>
) : null}
</>
@@ -0,0 +1,64 @@
import { useRef, useState } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { InvestigationExpandCard } from "./investigation-expand-card";
describe("investigation card reading", () => {
it("freezes the reading revision until the reader requests the latest content, then restores focus", async () => {
const user = userEvent.setup();
const { rerender } = render(<InvestigationExpandCard title="当前结果" preview={<p>结论一预览</p>} revision="1"><p>结论一正文</p></InvestigationExpandCard>);
const trigger = screen.getByRole("button", { name: "展开当前结果" });
await user.click(trigger);
const dialog = screen.getByRole("dialog", { name: "当前结果" });
expect(document.body.querySelector(".investigation-detail-backdrop")).toBeInTheDocument();
rerender(<InvestigationExpandCard title="当前结果" preview={<p>结论二预览</p>} revision="2"><p>结论二正文</p></InvestigationExpandCard>);
expect(dialog).toHaveTextContent("结论一正文");
expect(dialog).not.toHaveTextContent("结论二正文");
expect(within(dialog).getByRole("status")).toHaveTextContent("当前保留正在阅读的版本");
await user.click(within(dialog).getByRole("button", { name: "查看最新内容" }));
expect(dialog).toHaveTextContent("结论二正文");
expect(within(dialog).queryByRole("status")).not.toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(trigger).toHaveFocus());
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("moves the same live control into the reader and retains a visible inert preview", async () => {
const user = userEvent.setup();
function Controls() {
const [count, setCount] = useState(0);
return <button type="button" onClick={() => setCount((value) => value + 1)}>缩放 {count}</button>;
}
render(<InvestigationExpandCard title="调查脉络" preview={null} reuseContent wide><Controls /></InvestigationExpandCard>);
const control = screen.getByRole("button", { name: "缩放 0" });
await user.click(control);
await user.click(screen.getByRole("button", { name: "展开调查脉络" }));
const dialog = screen.getByRole("dialog", { name: "调查脉络" });
expect(within(dialog).getByRole("button", { name: "缩放 1" })).toBe(control);
const snapshot = document.querySelector("[data-reading-preview]")!;
expect(snapshot).toBeVisible();
expect(snapshot).toHaveAttribute("aria-hidden", "true");
expect(snapshot).toHaveAttribute("inert");
await user.click(control);
await user.click(within(dialog).getByRole("button", { name: "关闭详情" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: "缩放 2" })).toBe(control);
expect(document.querySelector("[data-reading-preview]")).not.toBeInTheDocument();
});
it("allows a follow-up to own focus when it explicitly closes without restoring the trigger", async () => {
const user = userEvent.setup();
function Fixture() {
const target = useRef<HTMLInputElement>(null);
return <><label>继续输入<input ref={target} /></label>
<InvestigationExpandCard title="主要依据" preview={<p>依据预览</p>}>
{(close) => <button type="button" onClick={() => { close(false); requestAnimationFrame(() => target.current?.focus()); }}>继续核查</button>}
</InvestigationExpandCard></>;
}
render(<Fixture />);
await user.click(screen.getByRole("button", { name: "展开主要依据" }));
await user.click(screen.getByRole("button", { name: "继续核查" }));
await waitFor(() => expect(screen.getByLabelText("继续输入")).toHaveFocus());
});
});
@@ -1,9 +1,10 @@
import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react";
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
import { createPortal } from "react-dom";
import * as Dialog from "@radix-ui/react-dialog";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { motion, useReducedMotion } from "motion/react";
import { Maximize2, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
import { createReadingPreview } from "@/shared/ui/reading-preview";
import { cn } from "@/shared/ui/cn";
/** Report-specific progressive disclosure. The source stays mounted while reading. */
@@ -27,7 +28,6 @@ export function InvestigationExpandCard({
reuseContent?: boolean;
headingAccessory?: ReactNode;
}) {
const id = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef(true);
const [open, setOpen] = useState(false);
@@ -35,6 +35,7 @@ export function InvestigationExpandCard({
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
const previewSlot = useRef<HTMLDivElement | null>(null);
const snapshot = useRef<HTMLElement | null>(null);
const attachPreview = useCallback((slot: HTMLDivElement | null) => {
previewSlot.current = slot;
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
@@ -43,24 +44,22 @@ export function InvestigationExpandCard({
const preview = previewSlot.current;
if (!preview || !contentHost) return;
if (slot) {
// Reserve only the vacated inline space while expanded. The graph itself
// keeps its natural height, including after content updates or resizing.
// Use the layout height, not a bounding box scaled by the card animation.
preview.style.height = getComputedStyle(preview).height;
snapshot.current?.remove();
snapshot.current = createReadingPreview(contentHost);
preview.append(snapshot.current);
slot.appendChild(contentHost);
} else {
snapshot.current?.remove();
snapshot.current = null;
preview.appendChild(contentHost);
preview.style.removeProperty("height");
}
}, [contentHost]);
const reduced = useReducedMotion();
const duration = reduced || keyboard ? 0 : 0.24;
const transition = { duration, ease: [0.22, 1, 0.36, 1] as const };
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
const content = reading?.content ?? children;
return (
<Dialog.Root open={open} onOpenChange={(next) => {
<Dialog open={open} onOpenChange={(next) => {
setOpen(next);
if (next) {
restoreFocusRef.current = true;
@@ -68,69 +67,52 @@ export function InvestigationExpandCard({
onOpen?.();
}
}}>
<motion.section
<section
ref={contentRef}
aria-label={label}
className={cn("investigation-report-section investigation-expand-card", className)}
layoutId={reduced || keyboard ? undefined : id}
layoutDependency={open}
transition={transition}
style={{ borderRadius: 12 }}
>
<div className="investigation-card-heading">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
{headingAccessory}
</div>
<Dialog.Trigger asChild>
<DialogTrigger asChild>
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
<Maximize2 size={15} aria-hidden />
</Button>
</Dialog.Trigger>
</DialogTrigger>
</div>
{/* Contain child margins so the occupied and vacated slots measure alike. */}
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
</motion.section>
</section>
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
<Dialog.Portal forceMount>
<AnimatePresence initial={false}>
{open ? (
<Dialog.Overlay forceMount asChild key="backdrop">
<motion.div className="investigation-detail-backdrop"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
</Dialog.Overlay>
) : null}
{open ? (
<div className="investigation-detail-positioner" key="detail">
<Dialog.Content forceMount asChild aria-describedby={undefined}
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
<motion.section className={cn("investigation-detail-panel", wide && "investigation-detail-panel-wide")}
layoutId={reduced || keyboard ? undefined : id} layoutDependency={open}
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={transition} style={{ borderRadius: 12 }}>
<header className="investigation-detail-heading">
<Dialog.Title className="text-base font-semibold text-[var(--ink)]">{title}</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
</Dialog.Close>
</header>
<div className="investigation-detail-scroll">
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
</div>
{reading && revision !== undefined && reading.revision !== revision ? (
<div className="investigation-report-update" role="status">
<span>已有更新,当前保留正在阅读的版本。</span>
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
</div>
) : null}
</motion.section>
</Dialog.Content>
</div>
) : null}
</AnimatePresence>
</Dialog.Portal>
</Dialog.Root>
<DialogContent
className={cn("investigation-detail-panel bg-card", wide && "investigation-detail-panel-wide", keyboard && "investigation-detail-no-motion")}
overlayClassName={cn("investigation-detail-backdrop", keyboard && "investigation-detail-no-motion")}
data-agent-theme="chat" aria-describedby={undefined} showCloseButton={false}
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
<header className="investigation-detail-heading border-b border-border">
<DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
<DialogClose asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
</DialogClose>
</header>
<div className="investigation-detail-scroll">
<motion.div initial={reduced || keyboard ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
</motion.div>
</div>
{reading && revision !== undefined && reading.revision !== revision ? (
<div className="investigation-report-update" role="status">
<span>已有更新,当前保留正在阅读的版本。</span>
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
import { forwardRef, useId, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useIsPresent } from "motion/react";
import { ChevronDown, FileText } from "lucide-react";
import { Button } from "@/shared/ui/button";
import {
presentationFade,
presentationTransition
} from "../utils/investigation-presentation-motion";
// Forward the element ref so AnimatePresence can remove a card without jumping its siblings.
export const PresentationItem = forwardRef<
HTMLDivElement,
{ immediate: boolean; children: ReactNode }
>(function PresentationItem({ immediate, children }, ref) {
const present = useIsPresent();
return (
<motion.div
ref={ref}
layout={immediate ? false : "position"}
{...presentationFade(immediate)}
inert={!present || undefined}
aria-hidden={!present || undefined}
>
{children}
</motion.div>
);
});
export const PresentationViewport = forwardRef<
HTMLDivElement,
{ immediate: boolean; id: string; children: ReactNode }
>(function PresentationViewport({ immediate, id, children }, ref) {
const present = useIsPresent();
return (
<motion.div
ref={ref}
{...presentationFade(immediate)}
inert={!present || undefined}
aria-hidden={!present || undefined}
id={present ? id : undefined}
data-investigation-content={present ? "" : undefined}
className="relative min-h-0 space-y-3 overflow-y-auto overscroll-contain p-3 [scrollbar-gutter:stable]"
>
{children}
</motion.div>
);
});
export function PresentationReferences({
entries,
immediate
}: {
entries: Array<{ id: string; text: string }>;
immediate: boolean;
}) {
const [open, setOpen] = useState(false);
const id = useId();
return (
<div className="mt-3 border-t border-border pt-1">
<Button
type="button"
variant="ghost"
className="h-10 w-full justify-between gap-2 px-0 text-xs font-normal text-muted-foreground"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
>
<span className="inline-flex items-center gap-1.5">
<FileText size={13} aria-hidden />
查看依据<span className="tabular-nums">{entries.length}</span>
</span>
<motion.span
className="inline-flex"
animate={{ rotate: open ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<ChevronDown size={13} aria-hidden />
</motion.span>
</Button>
<AnimatePresence initial={false}>
{open ? (
<motion.ul
key="references"
id={id}
{...presentationFade(immediate)}
className="space-y-2 pb-2 pt-1 text-xs leading-5 text-muted-foreground"
>
{entries.map((entry) => (
<li key={entry.id} className="break-words [overflow-wrap:anywhere]">
{entry.text}
</li>
))}
</motion.ul>
) : null}
</AnimatePresence>
</div>
);
}
@@ -1,55 +1,354 @@
import { useState } from "react";
import { Eye, EyeOff, LocateFixed, X } from "lucide-react";
import { AgentChartView } from "@/features/agent";
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import {
PresentationItem,
PresentationViewport,
PresentationReferences
} from "./investigation-presentation-motion";
import {
presentationFade,
presentationTransition
} from "../utils/investigation-presentation-motion";
import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = {
task: OperationalTask; charts: InvestigationChart[]; overlay: InvestigationOverlay | null;
cameraPaused: boolean; status: "connecting" | "live" | "disconnected" | "error"; error: string | null;
width: number; relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
onResumeCamera: () => void; onCloseChart: (id: string) => void;
onToggleOverlay: () => void; onFitOverlay: () => void;
blocks?: AnswerUiBlock[];
records?: Array<Record<string, unknown>>;
evidence?: Array<{ evidence_id: string; summary: string }>;
history?: PresentationHistoryControls;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
scope?: string;
embedded?: boolean;
task: OperationalTask | null;
localPreview?: boolean;
charts: InvestigationChart[];
overlay: InvestigationOverlay | null;
cameraPaused: boolean;
status: "connecting" | "live" | "history" | "disconnected" | "error";
error: string | null;
width: number;
relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
onResumeCamera: () => void;
onCloseChart: (id: string) => void;
onToggleOverlay: () => void;
onFitOverlay: () => void;
onToggleLayer: (id: string, visible: boolean) => void;
};
export function InvestigationPresentationPanel({ task, charts, overlay, cameraPaused, status, error, width, relatedLayers,
onResumeCamera, onCloseChart, onToggleOverlay, onFitOverlay, onToggleLayer }: Props) {
const statusLabels = {
connecting: "连接中",
live: "实时跟随",
history: "已载入历史",
disconnected: "连接中断",
error: "部分未完成"
};
export function InvestigationPresentationPanel({
scope = "investigation",
embedded = false,
task,
blocks = [],
records = [],
evidence,
history,
onLocateTargets,
localPreview = false,
charts,
overlay,
cameraPaused,
status,
error,
width,
relatedLayers,
onResumeCamera,
onCloseChart,
onToggleOverlay,
onFitOverlay,
onToggleLayer
}: Props) {
const [collapsed, setCollapsed] = useState(false);
const references = (ids: string[]) => <Disclosure label="查看依据" className="mt-2">
{ids.map((id) => <p key={id} className="py-1 text-xs leading-5 text-[var(--ink-muted)]">{task.evidence.find((item) => item.evidence_id === id)?.summary ?? `关联资料:${id}`}</p>)}
</Disclosure>;
return <aside aria-label="调查实时展示" data-testid="investigation-presentation-panel"
className="surface-reading pointer-events-auto absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 max-h-[55dvh] overflow-y-auto rounded-xl border border-[var(--surface-divider)] p-3 text-[var(--ink)] shadow-xs"
style={{ width, maxWidth: "calc(100% - 24px)" }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold">调查实时展示</p>
<Button variant="ghost" size="sm" onClick={() => setCollapsed(!collapsed)}>{collapsed ? "展开展示" : "收起展示"}</Button>
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)]">{status === "connecting" ? "连接中" : status === "disconnected" ? "连接中断" : status === "error" ? "部分展示未完成" : "跟随所选调查"}</p>
{error ? <p role="status" className="mt-2 text-xs text-[var(--status-warning-foreground)]">{error}</p> : null}
{cameraPaused ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs"><span>镜头跟随已暂停</span><Button size="sm" variant="outline" onClick={onResumeCamera}>恢复跟随</Button></div> : null}
{!collapsed ? <>
{relatedLayers.length ? <div aria-label="调查相关图层" className="mt-2 flex flex-wrap gap-1">
{relatedLayers.map((layer) => <Button key={layer.id} variant="outline" size="sm" aria-pressed={layer.visible} onClick={() => onToggleLayer(layer.id, !layer.visible)}>{layer.visible ? <Eye size={14} aria-hidden /> : <EyeOff size={14} aria-hidden />}{layer.label}</Button>)}
</div> : null}
{overlay ? <section aria-label="调查覆盖层" className="mt-3 border-t border-[var(--surface-divider)] pt-3">
<p className="text-sm font-medium">{overlay.title}</p>
<p className="mt-1 text-xs text-[var(--ink-muted)]">{overlay.mode === "heatmap" ? "记录集中度,颜色不表示已确认影响范围" : "调查范围与关联空间资料"}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onToggleOverlay}>{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}</Button>
<Button size="sm" variant="outline" onClick={onFitOverlay}><LocateFixed size={14} aria-hidden />定位覆盖层</Button>
const [keyboardAction, setKeyboardAction] = useState(false);
const reducedMotion = useReducedMotion();
const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId();
const reviewing = history?.selectedId != null;
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
const references = (ids: string[]) =>
ids.length ? (
<PresentationReferences
immediate={immediate}
entries={ids.map((id) => ({
id,
text:
(evidence ?? task?.evidence)?.find((item) => item.evidence_id === id)?.summary ??
(localPreview && id === "dev:synthetic"
? "合成测试数据,未保存为调查证据。"
: `关联资料:${id}`)
}))}
/>
) : null;
return (
<motion.aside
layout={immediate ? false : "size"}
{...presentationFade(immediate)}
onKeyDownCapture={() => setKeyboardAction(true)}
onPointerDownCapture={() => setKeyboardAction(false)}
aria-label={title}
data-testid="investigation-presentation-panel"
data-presentation-scope={scope}
data-investigation-collapsed={collapsed}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
>
<motion.header
layout={immediate ? false : "position"}
transition={presentationTransition(immediate)}
className="shrink-0 border-b border-border px-3 py-2.5"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<h2 className="text-sm font-semibold leading-6">{title}</h2>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
{reviewing
? "历史回看"
: scope === "chat"
? "对话联动"
: localPreview
? "本地测试"
: statusLabels[status]}
</Badge>
<span className="text-[11px] text-muted-foreground tabular-nums">
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
</span>
{reviewing && pending > 0 ? (
<span role="status" className="text-[11px] text-muted-foreground">
后续 {pending} 个动作
</span>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label={collapsed ? "展开展示" : "收起展示"}
title={collapsed ? "展开展示" : "收起展示"}
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setCollapsed(!collapsed)}
>
<motion.span
className="inline-flex"
animate={{ rotate: collapsed ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<ChevronDown size={16} aria-hidden />
</motion.span>
</Button>
</div>
{references([overlay.evidence_id])}
</section> : null}
{charts.map((item) => <section key={item.chart_id} data-investigation-chart={item.chart_id} className="mt-3 border-t border-[var(--surface-divider)] pt-3">
<div className="mb-2 flex justify-end"><Button variant="ghost" size="icon" aria-label={`关闭图表 ${item.chart.title}`} onClick={() => onCloseChart(item.chart_id)}><X size={16} /></Button></div>
<AgentChartView chart={item.chart} />
{references(item.evidence_refs)}
</section>)}
</> : null}
</aside>;
<p
className={
history?.entries.length
? "sr-only"
: "mt-1.5 text-[11px] leading-4 text-muted-foreground"
}
>
{selected
? `${selected.taskTitle ? `${selected.taskTitle} · ` : ""}${new Date(selected.createdAt).toLocaleTimeString("zh-CN", { hour12: false })} · ${selected.label}${pending ? ` · 后续 ${pending} 个动作` : ""}`
: scope === "chat"
? "展示当前对话的地图操作与结果"
: localPreview
? "仅当前浏览器,不写入调查任务"
: status === "history"
? "已载入所选调查保存的展示结果"
: "跟随所选调查,展示地图操作与结果"}
</p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
</motion.header>
<AnimatePresence initial={false} mode="popLayout">
{!collapsed ? (
<PresentationViewport
key={history?.selectedId ?? "current"}
immediate={immediate}
id={contentId}
>
{selected?.message ? (
<p role="status" className="text-xs leading-5 text-muted-foreground">
{selected.message}
</p>
) : null}
{!reviewing && !localPreview && error ? (
<p
role="status"
className="rounded-md border border-border px-2.5 py-2 text-xs leading-5 text-muted-foreground"
>
{error}
</p>
) : null}
<AnimatePresence initial={false} mode="popLayout">
{cameraPaused ? (
<PresentationItem key="camera" immediate={immediate}>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/50 px-2.5 py-2 text-xs">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Pause size={13} aria-hidden />
镜头跟随已暂停
</span>
<Button type="button" size="sm" variant="outline" onClick={onResumeCamera}>
{localPreview || scope === "chat" ? "恢复自动定位" : "恢复跟随"}
</Button>
</div>
</PresentationItem>
) : null}
</AnimatePresence>
{relatedLayers.length ? (
<section aria-label="调查相关图层" className="space-y-2">
<h3 className="text-[11px] font-medium text-muted-foreground">相关图层</h3>
<div className="flex flex-wrap gap-2">
{relatedLayers.map((layer) => (
<Button
key={layer.id}
type="button"
variant="outline"
size="sm"
className="h-auto min-h-8 whitespace-normal text-left"
disabled={reviewing}
aria-pressed={layer.visible}
onClick={() => onToggleLayer(layer.id, !layer.visible)}
>
{layer.visible ? (
<Eye size={14} aria-hidden />
) : (
<EyeOff size={14} aria-hidden />
)}
{layer.label}
</Button>
))}
</div>
</section>
) : null}
<AnimatePresence initial={false} mode="popLayout">
{overlay ? (
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
<CardHeader className="space-y-1.5 p-3 pb-2">
<div className="flex items-start gap-2">
<MapPinned
size={15}
className="mt-0.5 shrink-0 text-muted-foreground"
aria-hidden
/>
<h3 className="min-w-0 break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{overlay.title}
</h3>
</div>
<p className="text-[11px] leading-5 text-muted-foreground">
{localPreview
? "本地空间预览,范围以输入 JSONL 为准"
: overlay.mode === "heatmap"
? "记录集中度,颜色不表示已确认影响范围"
: "调查范围与关联空间资料"}
</p>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={reviewing}
onClick={onToggleOverlay}
>
{overlay.visible ? (
<EyeOff size={14} aria-hidden />
) : (
<Eye size={14} aria-hidden />
)}
{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}
</Button>
<Button type="button" size="sm" variant="outline" onClick={onFitOverlay}>
<LocateFixed size={14} aria-hidden />
定位覆盖层
</Button>
</div>
{references([overlay.evidence_id])}
</CardContent>
</Card>
</PresentationItem>
) : null}
{charts.map((item) => (
<PresentationItem key={item.chart_id} immediate={immediate}>
<Card
data-investigation-chart={item.chart_id}
role="group"
aria-label={item.chart.title}
className="border-border shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
<div className="min-w-0">
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{item.chart.title}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
{references(item.evidence_refs)}
</CardContent>
</Card>
</PresentationItem>
))}
</AnimatePresence>
<PresentationBusinessResults
blocks={blocks}
records={records}
reviewing={reviewing}
onLocateTargets={onLocateTargets}
/>
{!charts.length &&
!overlay &&
!blocks.length &&
!records.length &&
!relatedLayers.length ? (
<p className="text-xs leading-5 text-muted-foreground">
当前没有展示结果,可切换历史动作回看地图与结果。
</p>
) : null}
</PresentationViewport>
) : null}
</AnimatePresence>
</motion.aside>
);
}
@@ -0,0 +1,50 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { OperationalTask, TaskQuestionRequest } from "../api/operational-task-client";
import { InvestigationQuestions } from "./investigation-questions";
const question: TaskQuestionRequest = {
question_request_id: "70000000-0000-4000-8000-000000000001", task_id: "70000000-0000-4000-8000-000000000002",
run_id: "70000000-0000-4000-8000-000000000003", source_session_id: "s", execution_generation: 1,
status: "pending", version: 1, created_at: "2026-10-30T01:00:00Z", answered_at: null, answered_by: null, answers: [],
questions: [{ question_id: "flow", title: "流量依据", body: "是否有流量记录?", options: [{ id: "unknown", label: "目前未知" }], mode: "single", allow_custom: true, required: true }]
};
const task = { questions: [question], execution_generation: 1, lifecycle_status: "waiting_human", active_run: null,
permissions: { can_answer_questions: true } } as OperationalTask;
describe("investigation questions", () => {
it("requires an answer, retains input after failure and retries the same request id", async () => {
const onAnswer = vi.fn().mockRejectedValueOnce(new Error("网络失败")).mockResolvedValueOnce({});
render(<InvestigationQuestions task={task} onAnswer={onAnswer} />);
const submit = screen.getByRole("button", { name: "提交回答并继续调查" });
expect(submit).toBeDisabled();
fireEvent.click(screen.getByRole("radio", { name: "目前未知" }));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "请保留为未知" } });
fireEvent.click(submit);
expect(await screen.findByRole("alert")).toHaveTextContent("网络失败");
expect(screen.getByRole("textbox")).toHaveValue("请保留为未知");
fireEvent.click(submit);
await waitFor(() => expect(onAnswer).toHaveBeenCalledTimes(2));
expect(onAnswer.mock.calls[0]).toEqual(onAnswer.mock.calls[1]);
expect(onAnswer.mock.calls[0].slice(0, 3)).toEqual([question.question_request_id, 1,
[{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "请保留为未知" }]]);
expect(await screen.findByText(/回答已保存,调查已排队继续/)).toBeVisible();
});
it.each([
{ ...task, permissions: { ...task.permissions, can_answer_questions: false } },
{ ...task, active_run: { status: "running" } },
{ ...task, execution_generation: 2 }
])("does not answer without permission or for active/stale runs", (value) => {
render(<InvestigationQuestions task={value as OperationalTask} onAnswer={vi.fn()} />);
expect(screen.getByRole("radio")).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
});
it("shows stored option labels and text after reload without another answer action", () => {
render(<InvestigationQuestions task={{ ...task, questions: [{ ...question, status: "answered", version: 2,
answers: [{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "没有记录" }] }] }} />);
expect(screen.getByText("目前未知;没有记录")).toBeVisible();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
import { useRef, useState, type Ref } from "react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Radio } from "@/shared/ui/radio";
import { Textarea } from "@/shared/ui/textarea";
import { createOperationalRequestId, type AnswerTaskQuestion, type OperationalTask,
type TaskQuestionAnswer, type TaskQuestionRequest } from "../api/operational-task-client";
export function InvestigationQuestions({ task, onAnswer, sectionRef }: {
task: OperationalTask; onAnswer?: AnswerTaskQuestion; sectionRef?: Ref<HTMLElement>;
}) {
const questions = task.questions ?? [];
if (!questions.length) return null;
return <section ref={sectionRef} tabIndex={-1} aria-label="调查提问" className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">补充回答</h3>
<p className="text-xs leading-5 text-muted-foreground">在本报告中选择或填写答案,提交后继续当前调查。</p>
{questions.map((request) => <TaskQuestionCard key={`${request.question_request_id}:${request.version}`}
request={request} onAnswer={onAnswer}
canAnswer={Boolean(task.permissions.can_answer_questions && task.lifecycle_status === "waiting_human"
&& !task.active_run && task.execution_generation === request.execution_generation)} />)}
</section>;
}
function TaskQuestionCard({ request, onAnswer, canAnswer }: {
request: TaskQuestionRequest; onAnswer?: AnswerTaskQuestion; canAnswer: boolean;
}) {
const [answers, setAnswers] = useState<TaskQuestionAnswer[]>(() => request.questions.map((q) => ({
question_id: q.question_id, selected_option_ids: [], custom_text: ""
})));
const [busy, setBusy] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const attempt = useRef<{ body: string; id: string } | null>(null);
const pending = request.status === "pending" && !submitted;
const disabled = !canAnswer || !onAnswer || busy || !pending;
const valid = request.questions.every((q, index) => !q.required || answers[index].selected_option_ids.length
|| (q.allow_custom && answers[index].custom_text.trim()));
const update = (index: number, patch: Partial<TaskQuestionAnswer>) => setAnswers((current) =>
current.map((answer, i) => i === index ? { ...answer, ...patch } : answer));
const submit = async () => {
if (disabled || !valid) return;
const body = JSON.stringify(answers);
if (attempt.current?.body !== body) attempt.current = { body, id: createOperationalRequestId() };
setBusy(true); setError(null);
try {
await onAnswer!(request.question_request_id, request.version, answers, attempt.current.id);
setSubmitted(true);
} catch (cause) { setError(cause instanceof Error ? cause.message : "提交失败,请重试"); }
finally { setBusy(false); }
};
return <article aria-label="调查需要补充的信息" aria-busy={busy} className="space-y-4 rounded-xl border border-border bg-card p-4 text-card-foreground">
<p role="status" className="text-xs text-muted-foreground">第 {request.execution_generation} 轮调查 · {submitted || request.status === "answered"
? "回答已保存" : request.status === "cancelled" ? "提问已失效" : "等待回答"}</p>
{request.questions.map((question, index) => {
const answer = answers[index];
const saved = request.answers.find((item) => item.question_id === question.question_id);
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0 space-y-2">
<legend className="text-sm font-medium">{question.title}{question.required && pending ? "(必填)" : ""}</legend>
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{question.body}</p>
{pending ? <>
{question.options.map((option) => <label key={option.id} className="flex min-h-11 items-center gap-2 rounded-lg border border-border p-2 text-sm">
{question.mode === "single" ? <Radio name={`${request.question_request_id}:${question.question_id}`}
checked={answer.selected_option_ids.includes(option.id)} onChange={() => update(index, { selected_option_ids: [option.id] })} />
: <Checkbox checked={answer.selected_option_ids.includes(option.id)} onChange={(event) => update(index, {
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id)
})} />}
<span className="min-w-0 break-words">{option.label}</span>
</label>)}
{question.allow_custom ? <Textarea aria-label={`${question.title}:补充说明`} maxLength={2000} value={answer.custom_text}
placeholder="填写回答或补充说明" onChange={(event) => update(index, { custom_text: event.target.value })} /> : null}
</> : <p className="whitespace-pre-wrap break-words text-sm">{saved
? [...question.options.filter((o) => saved.selected_option_ids.includes(o.id)).map((o) => o.label), saved.custom_text].filter(Boolean).join(";") || "未填写"
: submitted ? "回答已保存,调查已排队继续。" : "该提问不再接受回答。"}</p>}
</fieldset>;
})}
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
{pending ? <div className="space-y-2">
<p className="text-xs text-muted-foreground">回答将作为用户补充信息供调查参考;提交后继续调查。</p>
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => void submit()}>{busy ? "正在提交…" : "提交回答并继续调查"}</Button>
</div> : null}
</article>;
}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { InvestigationReportBlocks } from "./investigation-report-blocks";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
@@ -22,20 +22,16 @@ const snapshot: OperationalTaskReport = {
};
describe("formal report interactive content", () => {
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => {
it("shows saved evidence facts without quoting or dispatching work", async () => {
const onContinue = vi.fn();
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible();
await waitFor(() => expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible());
expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "针对这部分提问" }));
expect(onContinue).toHaveBeenCalledWith(expect.objectContaining({
context: { report_revision: 3 }, reportGeneration: 2,
reference: { title: "演练调查报告 · 报告第 3 版 · 监测依据",
text: expect.stringContaining("保存的监测来源\n类别:演练数据") },
}));
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
expect(screen.queryByText("引用依据:保存的监测来源")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(onContinue).not.toHaveBeenCalled();
});
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
const next = structuredClone(snapshot);
@@ -44,7 +40,28 @@ describe("formal report interactive content", () => {
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByText("分析待核验。")).toBeVisible();
expect(container.querySelector("iframe")).toBeNull();
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
});
it("keeps references not already represented by an evidence card", () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].evidence_refs.push("other-evidence");
const expandedTask = { evidence: [...task.evidence, { ...task.evidence[0], evidence_id: "other-evidence", source_label: "另一条监测来源" }] };
render(<InvestigationReportBlocks task={expandedTask} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByText("保存的监测来源")).toBeVisible();
expect(screen.getByText("引用依据:另一条监测来源")).toBeVisible();
expect(screen.queryByText("引用依据:保存的监测来源、另一条监测来源")).not.toBeInTheDocument();
});
it("keeps evidence references for tables without evidence cards", () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.spec = { root: "table", elements: { table: {
type: "Table", children: [], props: { title: "监测记录", columns: ["对象"], rows: [["北区"]] },
} } };
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByRole("table")).toHaveTextContent("北区");
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
});
it("turns report form input into a versioned conversation draft", async () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: {
@@ -58,5 +75,9 @@ describe("formal report interactive content", () => {
expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible();
expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置");
expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 });
expect(onContinue.mock.calls[0][0].reportGeneration).toBe(2);
expect(onContinue.mock.calls[0][0].reference.title).toBe("演练调查报告 · 报告第 3 版 · 补充资料");
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
});
});
@@ -24,7 +24,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
return <section className="investigation-report-section space-y-4" aria-label="交互分析">
<div>
<h3 className="text-sm font-semibold">数据与分析</h3>
<p className="mt-1 text-xs text-muted-foreground">探索报告中的数据,选择内容后可引用到对话。</p>
<p className="mt-1 text-xs text-muted-foreground">查看报告中的数据与依据;定位和补充资料使用各自入口。</p>
</div>
{blocks.map((item) => {
const parsed = parseAnswerUiBlock(item.block);
@@ -49,14 +49,17 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
}))
} } : null;
const shownEvidence = new Set(block ? Object.values(block.spec.elements)
.filter((node) => node.type === "Evidence").map((node) => node.props.evidence_id) : []);
const remainingRefs = item.evidence_refs.filter((id) => !shownEvidence.has(id));
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
{block ? <AnswerUiView block={block} onAsk={quote} submitMode="draft"
{block ? <AnswerUiView block={block} submitMode="draft"
onSubmit={(text) => quote({ title: "补充资料", text })}
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
<p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{evidence.map((entry) => entry.source_label).join("、") || "关联记录当前不可用"}
</p>
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
</p> : null}
</div>;
})}
</section>;
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { InvestigationQuestions } from "./investigation-questions";
import { History, LoaderCircle, MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
@@ -34,6 +35,7 @@ type Props = {
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
renderAction: (action: OperationalTaskAction) => ReactNode;
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
@@ -82,6 +84,12 @@ function investigationTimeline(
detail: snapshot.report.current_conclusion
});
}
task.questions?.forEach((question) => {
items.push({ id: `question:${question.question_request_id}`, occurredAt: question.created_at,
title: "调查提出补充问题", detail: question.questions.map((item) => item.title).join(";") });
if (question.answered_at) items.push({ id: `answer:${question.question_request_id}`, occurredAt: question.answered_at,
title: "已收到调查回答", detail: "回答已保存为用户补充信息,调查接续处理。" });
});
task.actions.forEach((action) => {
const title = actionTitle(action);
items.push({
@@ -133,10 +141,14 @@ export function InvestigationTaskReport({
renderAction,
renderEvidence,
onLoadReports,
onAnswerQuestion,
onReportViewed,
focusActionId,
onLocate
}: Props) {
const questionsRef = useRef<HTMLElement>(null);
const hasPendingQuestions = task.questions?.some((request) => request.status === "pending"
&& request.execution_generation === task.execution_generation) ?? false;
// Null follows live task content. Only a deliberate history selection freezes a report.
const [selection, setSelection] = useState<ReportSelection | null>(null);
const [history, setHistory] = useState<OperationalTaskReport[]>([]);
@@ -313,6 +325,8 @@ export function InvestigationTaskReport({
</div>
</header>
<InvestigationQuestions task={task} onAnswer={onAnswerQuestion} sectionRef={questionsRef} />
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
{latestAvailable ? (
@@ -489,14 +503,19 @@ export function InvestigationTaskReport({
<Button
variant="outline"
size="sm"
onClick={() =>
discuss(
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`,
{ gap_id: gap.gap_id }
)
}
onClick={() => {
if (hasPendingQuestions) {
questionsRef.current?.scrollIntoView({ block: "start" });
questionsRef.current?.focus({ preventScroll: true });
} else {
discuss(
`请帮我讨论“${formatInvestigationText(gap.missing_data)}”这个缺口的查询范围和补齐途径。`,
{ gap_id: gap.gap_id }
);
}
}}
>
补充事实
{hasPendingQuestions ? "回答当前调查问题" : "讨论此缺口"}
</Button>
</div>
</div>
@@ -548,12 +567,6 @@ export function InvestigationTaskReport({
) : null}
</>
)}
onAsk={(node) =>
discuss(
`请解释调查脉络中的“${node.label}”及其关联依据。`,
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
)
}
/>
) : null}
@@ -50,6 +50,24 @@ describe("MapDevPanel", () => {
expect(onFollowChange).toHaveBeenCalledWith(false);
});
it("keeps map inputs when switching between investigation and map tools", async () => {
const user = userEvent.setup();
const commands = createCommands();
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
detailFeature={null} onClose={vi.fn()} onInvestigationFollowChange={vi.fn()} />);
expect(screen.getByRole("tab", { name: "调查测试" })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("button", { name: "定位并高亮" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "地图调试" }));
const featureId = screen.getByLabelText("要素 ID(可多个)");
await user.clear(featureId);
await user.type(featureId, "DEMO-PIPE-03");
await user.click(screen.getByRole("tab", { name: "调查测试" }));
expect(screen.queryByRole("button", { name: "重置地图调试效果" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "地图调试" }));
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("DEMO-PIPE-03");
expect(commands.resetAll).not.toHaveBeenCalled();
});
it("updates a pipe with its signed velocity", async () => {
const user = userEvent.setup();
const commands = createCommands();
@@ -6,25 +6,27 @@ import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
LAYER_GROUP_GEOMETRIES,
type LayerGroupId,
type LayerGroupStylePatch
} from "../map/layer-group-style";
import { MAP_STYLE_TOKENS } from "../map/map-colors";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { getNumericFeatureProperties } from "../map/value-label";
import type {
FeatureBatchTarget,
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
type AvailableWaterNetworkSourceId
} from "../map/sources";
import type { DetailFeature } from "../types";
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
type MapDevPanelProps = {
commands: WorkbenchMapCommands;
@@ -34,6 +36,7 @@ type MapDevPanelProps = {
onClose: () => void;
investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void;
investigationTools?: InvestigationDevToolsProps;
};
const GROUP_LABELS = Object.fromEntries(
@@ -109,8 +112,10 @@ export function MapDevPanel({
flowDataSource = "mock",
onClose,
investigationFollow = true,
onInvestigationFollowChange
onInvestigationFollowChange,
investigationTools
}: MapDevPanelProps) {
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681");
const [property, setProperty] = useState("diameter");
@@ -241,17 +246,17 @@ export function MapDevPanel({
return (
<aside
aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-foreground">
地图调试{" "}
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span>
开发测试{" "}
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
</h2>
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
按功能展开 · 设置仅本次页面会话生效
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
调查联动验证与地图工具 · 仅当前页面生效
</p>
</div>
<Button
@@ -265,429 +270,436 @@ export function MapDevPanel({
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div
role="alert"
className="status-tone-danger my-3 break-words rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
>
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
{onInvestigationFollowChange ? (
<PanelSection
title="调查联动"
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
initiallyOpen
status={investigationFollow ? "已开启" : "已关闭"}
>
<ToggleField
label="跟随所选调查"
checked={investigationFollow}
onChange={onInvestigationFollowChange}
/>
</PanelSection>
) : null}
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
>
<div className="space-y-3">
<FieldSelect
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
variant="outline"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection>
<PanelSection
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<Input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<Input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || featureIds.length !== 1}
onClick={() => setFlowPipeId(featureIds[0])}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后显示演示流向</StatusText>
)}
</PanelSection>
<PanelSection
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
label="业务图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
{geometry === "line" ? (
<>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
<FieldSelect
label="线型"
value={String(draft.dash)}
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
options={[
{ value: "original", label: "原始" },
{ value: "solid", label: "实线" },
{ value: "dashed", label: "虚线" }
]}
/>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border px-4 py-2">
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
<TabsTrigger value="map">地图调试</TabsTrigger>
</TabsList>
</div>
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{onInvestigationFollowChange ? (
<section aria-label="调查联动" className="space-y-4">
<div className="space-y-2">
<ToggleField label="跟随所选调查" checked={investigationFollow} onChange={onInvestigationFollowChange} />
<p className="text-xs leading-5 text-muted-foreground">选中调查后,跟随新的地图高亮、图表和影响范围指令。</p>
</div>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
{investigationTools ? <InvestigationDevTools key={`${investigationTools.task?.task_id}:${investigationTools.task?.execution_generation}`} {...investigationTools} /> : null}
</section>
) : null}
</TabsContent>
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
>
<div className="space-y-3">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<RangeField
label="半径倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.radiusMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, radiusMultiplier: value }))
}
/>
<RangeField
label="描边倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.strokeMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, strokeMultiplier: value }))
}
/>
{groupId !== "junctions" ? (
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
variant="outline"
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
variant="outline"
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection>
<PanelSection
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<Input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<Input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || featureIds.length !== 1}
onClick={() => setFlowPipeId(featureIds[0])}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后显示演示流向</StatusText>
)}
</PanelSection>
<PanelSection
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
{geometry === "line" ? (
<>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="图标倍率"
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.iconMultiplier)}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
) : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
</Button>
<Button
type="button"
onClick={() => commands.resetLayerGroupStyle(groupId)}
className="w-full text-xs"
variant="outline"
>
重置本组
</Button>
</div>
{controllerState.styledGroups.includes(groupId) ? (
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
<PanelSection
title="数值标注"
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
status={
hasActiveLabels
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
: undefined
}
>
<FieldSelect
label="标注图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="线型"
value={String(draft.dash)}
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
options={[
{ value: "original", label: "原始" },
{ value: "solid", label: "实线" },
{ value: "dashed", label: "虚线" }
]}
/>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
</div>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="半径倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.radiusMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, radiusMultiplier: value }))
}
/>
<RangeField
label="描边倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.strokeMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, strokeMultiplier: value }))
}
/>
{groupId !== "junctions" ? (
<RangeField
label="图标倍率"
min={0.5}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
step={0.1}
value={Number(draft.iconMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</div>
) : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
</Button>
<Button
type="button"
onClick={() => commands.resetLayerGroupStyle(groupId)}
className="w-full text-xs"
variant="outline"
>
重置本组
</Button>
</div>
{controllerState.styledGroups.includes(groupId) ? (
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
<PanelSection
title="数值标注"
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
status={
hasActiveLabels
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
: undefined
}
>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="标注图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</TabsContent>
</Tabs>
<div className="shrink-0 border-t border-border p-3" hidden={activeTab !== "map"}>
<Button
type="button"
onClick={() => commands.resetAll()}
@@ -718,12 +730,12 @@ function PanelSection({
return (
<Disclosure
initiallyOpen={initiallyOpen}
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground"
className="border-b border-border py-2 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground [&>summary]:text-sm"
label={
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="font-semibold">{title}</span>
{status ? (
<span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]">
<span className="shrink-0 text-[11px] font-normal text-muted-foreground">
{status}
</span>
) : null}
@@ -731,7 +743,7 @@ function PanelSection({
}
>
<div className="space-y-3 px-2 pb-3 pt-1">
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p>
<p className="text-[11px] leading-5 text-muted-foreground">{description}</p>
{children}
</div>
</Disclosure>
@@ -741,7 +753,7 @@ function PanelSection({
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block min-w-0">
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span>
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
@@ -757,7 +769,7 @@ function ToggleField({
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 py-2 text-xs font-medium text-foreground">
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-2.5 py-2 text-xs font-medium text-foreground">
<Checkbox
checked={checked}
onChange={(event) => onChange(event.target.checked)}
@@ -778,7 +790,7 @@ function ColorField({
}) {
return (
<Field label={label}>
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-input bg-background px-2">
<input
type="color"
value={value}
@@ -786,7 +798,7 @@ function ColorField({
aria-label={label}
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/>
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span>
<span className="text-xs font-medium tabular-nums text-muted-foreground">{value}</span>
</div>
</Field>
);
@@ -835,4 +847,4 @@ function StatusText({ children, tone = "success" }: { children: React.ReactNode;
}
const inputClassName =
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-foreground transition-colors";
"h-10 w-full rounded-lg border border-input bg-background px-2.5 text-xs text-foreground transition-colors";
@@ -0,0 +1,77 @@
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = {
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
reviewing: boolean;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
};
export function PresentationBusinessResults({
blocks,
records,
reviewing,
onLocateTargets
}: Props) {
if (!blocks.length && !records.length) return null;
return (
<section aria-label="展示结果" className="min-w-0 space-y-3">
{blocks.map((block) => (
<AnswerUiView
key={block.block_id}
block={block}
disabled={reviewing}
onLocate={
onLocateTargets && !reviewing
? (target) =>
onLocateTargets(
target.feature_ids.map((feature_id) => ({
source_id: target.layer,
feature_id
}))
)
: undefined
}
/>
))}
{!blocks.length && records.length ? (
<Card className="border-border shadow-none">
<CardHeader className="p-3 pb-2">
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
</CardHeader>
<CardContent className="overflow-x-auto px-3 pb-3 pt-0">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">调查数据摘要</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 font-medium">来源</th>
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="break-all py-1.5 pr-3">
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</td>
<td className="break-all py-1.5">{String(record.origin)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
) : null}
</section>
);
}
@@ -0,0 +1,60 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup";
import { PresentationViewHistory } from "../utils/presentation-view-history";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
afterEach(cleanup);
function entries() {
const history = new PresentationViewHistory();
history.record("压力曲线", "preview", null);
history.update({ ...history.current, records: [{ element_id: "V1" }] });
history.record("阀门列表", "preview", null);
return history.entries;
}
it("merges the latest result into current and skips its duplicate when going back", async () => {
const user = userEvent.setup();
const history = entries();
const onSelect = vi.fn();
render(
<PresentationHistorySwitcher
entries={history}
currentEntryId={history[1].id}
selectedId={null}
onSelect={onSelect}
/>
);
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
expect(screen.getAllByRole("option")).toHaveLength(2);
expect(screen.getByRole("option", { name: /当前(最新).*阀门列表/ })).toBeVisible();
expect(screen.getByRole("option", { name: /1\. 压力曲线/ })).toBeVisible();
expect(screen.queryByRole("option", { name: /^2\. 阀门列表/ })).not.toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "上一个动作" }));
expect(onSelect).toHaveBeenCalledWith(history[0].id);
});
it("keeps the final historical result selectable when current has been cleared", async () => {
const user = userEvent.setup();
const history = entries();
const onSelect = vi.fn();
render(
<PresentationHistorySwitcher
entries={history}
currentEntryId={null}
selectedId={null}
onSelect={onSelect}
/>
);
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
"当前(最新) · 2 个动作",
"2. 阀门列表 · 完成",
"1. 压力曲线 · 完成"
]);
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
expect(onSelect).toHaveBeenCalledWith(history[1].id);
});
@@ -0,0 +1,97 @@
import { ChevronLeft, ChevronRight, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" };
export function PresentationHistorySwitcher({
entries,
currentEntryId,
selectedId,
onSelect
}: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
const available = entries.filter(
(entry) => entry.status !== "running" && entry.id !== currentEntryId
);
const index =
selectedId === null
? available.length
: available.findIndex((entry) => entry.id === selectedId);
return (
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="上一个动作"
disabled={index <= 0}
onClick={() => onSelect(available[index - 1].id)}
>
<ChevronLeft size={14} aria-hidden />
</Button>
<Select
value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
>
<SelectTrigger
aria-label="切换展示动作"
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
sideOffset={4}
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
>
<SelectItem value="current" className="text-xs">
当前(最新)
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
{entries.length} 个动作
</SelectItem>
{entries
.slice()
.reverse()
.map((entry, position) =>
entry.id === currentEntryId ? null : (
<SelectItem
key={entry.id}
value={String(entry.id)}
disabled={entry.status === "running"}
className="whitespace-normal text-xs [&>span:last-child]:break-words"
>
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="下一个动作"
disabled={selectedId === null}
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
>
<ChevronRight size={14} aria-hidden />
</Button>
{selectedId !== null ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
onClick={() => onSelect(null)}
>
<Radio size={12} aria-hidden />
回到当前
</Button>
) : null}
</div>
);
}
@@ -26,7 +26,6 @@ import type {
} from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import { formatDateTime } from "@/shared/utils/date-time";
import { Disclosure } from "@/shared/ui/disclosure";
import {
StatusBadge,
StatusIcon as SemanticStatusIcon,
@@ -115,6 +114,7 @@ type ConditionDetailPanelProps = {
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -171,6 +171,7 @@ export function ConditionDetailPanel({
onContinueConversation,
onViewTaskExecutionHistory,
onTaskDecision,
onAnswerTaskQuestion,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
@@ -200,6 +201,7 @@ export function ConditionDetailPanel({
onContinueConversation={onContinueConversation}
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
onTaskDecision={onTaskDecision}
onAnswerTaskQuestion={onAnswerTaskQuestion}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
@@ -237,6 +239,7 @@ function ConditionRecordDetailPanel({
onContinueConversation,
onViewTaskExecutionHistory,
onTaskDecision,
onAnswerTaskQuestion,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
@@ -255,6 +258,7 @@ function ConditionRecordDetailPanel({
onContinueConversation: ConditionDetailPanelProps["onContinueConversation"];
onViewTaskExecutionHistory?: ConditionDetailPanelProps["onViewTaskExecutionHistory"];
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
onAnswerTaskQuestion?: ConditionDetailPanelProps["onAnswerTaskQuestion"];
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
onTaskConfirmDispatch?: ConditionDetailPanelProps["onTaskConfirmDispatch"];
@@ -322,6 +326,7 @@ function ConditionRecordDetailPanel({
<AgentExecutionPlanPanel
onLocate={onLocate}
task={condition.operationalTask}
onAnswerQuestion={onAnswerTaskQuestion ? (...args) => onAnswerTaskQuestion(condition.operationalTask!.task_id, ...args) : undefined}
onLoadReports={onLoadTaskReports ? (offset) => onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined}
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
focusActionId={focusActionId}
@@ -8,7 +8,6 @@ import {
X,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
@@ -87,6 +86,7 @@ type ScheduledConditionFeedProps = {
onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void>;
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -129,6 +129,7 @@ export function ScheduledConditionFeed({
onOpenTaskCollaboration,
onViewTaskExecutionHistory,
onTaskDecision,
onAnswerTaskQuestion,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
@@ -211,7 +212,6 @@ export function ScheduledConditionFeed({
: [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions, taskCategory]
);
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const headerSummary = loading && backendLoading
@@ -559,6 +559,7 @@ export function ScheduledConditionFeed({
onContinueConversation={(discussion) => handleContinueConversation(selectedCondition, discussion)}
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
onTaskDecision={onTaskDecision}
onAnswerTaskQuestion={onAnswerTaskQuestion}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
@@ -1,43 +0,0 @@
import { ShieldCheck } from "lucide-react";
import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client";
import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel";
import { investigationStatus } from "../utils/investigation-presentation";
type TaskConversationReviewProps = {
task: OperationalTask;
onAccessDecision: (requestId: string, version: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onActionDecision: (actionId: string, version: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onActionExecute: (actionId: string, version: number) => Promise<unknown>;
onDraftUpdate: (actionId: string, version: number, payload: Record<string, unknown>) => Promise<unknown>;
onConfirmDispatch: (actionId: string, version: number) => Promise<unknown>;
onActionReconcile: (actionId: string, version: number) => Promise<unknown>;
onRefresh: () => void;
};
export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute,
onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) {
const pendingAccess = (task.task_type === "event_investigation" ? [] : task.access_requests).filter((request) => request.status === "pending");
const pendingActions = task.actions.filter((action) => action.approval_status === "pending"
|| action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status));
const recentFacts = task.evidence.filter((item) => item.kind === "human_input")
.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, 3);
return <section aria-label="任务协作状态" className="surface-reading space-y-2 rounded-lg border border-[var(--surface-divider)] p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-[var(--ink)]"><ShieldCheck size={14} aria-hidden="true" />{task.title}</div>
<p className="text-xs leading-5 text-[var(--ink-muted)]">当前第 {task.execution_generation} 轮 · {investigationStatus(task)}</p>
{recentFacts.length > 0 ? <details>
<summary className="cursor-pointer py-1 text-xs text-[var(--ink-muted)]">最近已记录的人工事实 · {recentFacts.length} 条</summary>
<ul className="mt-1 space-y-2 text-xs leading-5 text-[var(--ink)]">{recentFacts.map((item) => <li key={item.evidence_id}>
<p>{item.summary}</p><p className="text-[var(--ink-muted)]">{item.source_label ?? "人工输入"} · 记录于 {new Date(item.recorded_at).toLocaleString("zh-CN", { hour12: false })}</p>
{item.observed_at ? <p className="text-[var(--ink-muted)]">现场时间 {new Date(item.observed_at).toLocaleString("zh-CN", { hour12: false })}</p> : null}
</li>)}</ul>
</details> : null}
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
canDecide={task.permissions.can_decide_access}
onDecision={(requestId, version, decision, scopes) => onAccessDecision(requestId, version, decision, scopes)}
onRefresh={onRefresh} />)}
{pendingActions.map((action) => <HumanActionCard key={action.action_id} task={task} action={action}
onDecision={onActionDecision} onExecute={onActionExecute} onDraftUpdate={onDraftUpdate}
onConfirmDispatch={onConfirmDispatch} onReconcile={onActionReconcile} onRefresh={onRefresh} />)}
</section>;
}
@@ -52,26 +52,27 @@ export function WorkbenchSection({
}) {
return (
<section
data-slot="workbench-section"
className={cn(
"overflow-hidden rounded-[14px] border border-[var(--surface-divider)]/90 bg-[var(--surface-reading)] shadow-[0_1px_2px_rgba(15,23,42,0.06)]",
className
)}
{...props}
>
<div className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
<div data-slot="workbench-section-header" className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
{icon ? (
<span className={cn("mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg", sectionTone[tone])}>
{icon}
</span>
) : null}
<div className="min-w-0 flex-1">
<div data-slot="workbench-section-heading" className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="text-[13px] font-semibold leading-5 text-slate-900">{title}</h3>
{meta}
</div>
{description ? <p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
{actions ? <div data-slot="workbench-section-actions" className="flex shrink-0 items-center gap-1">{actions}</div> : null}
</div>
<div className={cn("p-3.5", bodyClassName)}>{children}</div>
</section>
@@ -93,6 +94,8 @@ export function WorkbenchNotice({
const Icon = noticeIcon[tone];
return (
<div
data-slot="workbench-notice"
data-tone={tone}
className={cn(
"flex min-w-0 gap-2.5 rounded-r-lg border-l-2 px-3 py-2.5 text-xs leading-5",
noticeTone[tone],
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { ChatTransport, UIMessageChunk } from "ai";
import type { AgentApiClient } from "@/features/agent";
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
import type { AgentUiMessage } from "./agent-session-message-state";
@@ -8,6 +8,81 @@ import type { AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => {
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
vi.useFakeTimers();
const resources = new WorkbenchSessionResources();
const streamSession = vi.fn(async (_id, options) => {
const complete = streamSession.mock.calls.length > 1;
options.onEvent({ type: "state", sessionId: "a", isStreaming: !complete,
messages: [message("b", complete ? "recovered" : "partial")], runStatus: complete ? "completed" : "running" });
});
const transport = { sendMessages: vi.fn() } as unknown as ChatTransport<AgentUiMessage>;
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient, transport, {
id: "a", sessionId: "a", title: "核查", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat", isTitleManuallyEdited: false, messages: [message("older"), message("b")]
});
resources.activeAgent = runtime;
runtime.draft = "unsent";
runtime.readingPosition = { messageId: "older", offset: 12, atBottom: false };
try {
resumeAgentRuntime(resources, runtime);
await vi.advanceTimersByTimeAsync(0);
expect(streamSession).toHaveBeenCalledTimes(1);
expect(runtimeRunning(runtime)).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(streamSession).toHaveBeenCalledTimes(2);
expect(runtime.chat.messages).toEqual([message("older"), message("b", "recovered")]);
expect(runtime.draft).toBe("unsent");
expect(runtime.readingPosition).toEqual({ messageId: "older", offset: 12, atBottom: false });
expect(transport.sendMessages).not.toHaveBeenCalled();
expect(runtime.controller).toBeUndefined();
} finally { resources.dispose(); vi.useRealTimers(); }
});
it("stops retrying after permission loss or resource disposal", async () => {
vi.useFakeTimers();
for (const forbidden of [true, false]) {
const resources = new WorkbenchSessionResources();
const streamSession = vi.fn(async () => { throw forbidden ? new AgentSessionStreamError("forbidden", 403) : new TypeError("offline"); });
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient, {} as ChatTransport<AgentUiMessage>);
runtime.sessionId = "a";
resources.activeAgent = runtime;
try {
resumeAgentRuntime(resources, runtime);
await vi.advanceTimersByTimeAsync(0);
if (forbidden) expect(runtime.controller).toBeUndefined();
resources.dispose();
await vi.advanceTimersByTimeAsync(30_000);
expect(streamSession).toHaveBeenCalledTimes(1);
} finally { resources.dispose(); }
}
vi.useRealTimers();
});
it("recovers an interrupted Responses stream through a GET subscription without resending the prompt", async () => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
const sendMessages = vi.fn(async () => new ReadableStream<UIMessageChunk>({ start(value) { controller = value; } }));
const streamSession = vi.fn(async (_id, options) => options.onEvent({ type: "state", sessionId: "a", isStreaming: false,
runStatus: "completed", messages: [message("answer", "server completed")] }));
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient,
{ sendMessages, reconnectToStream: async () => null });
runtime.sessionId = "a";
resources.activeAgent = runtime;
try {
const request = runtime.chat.sendMessage({ text: "核查压力" });
await vi.waitFor(() => expect(controller).toBeDefined());
controller.enqueue({ type: "start", messageId: "answer" });
controller.enqueue({ type: "text-start", id: "text" });
controller.enqueue({ type: "text-delta", id: "text", delta: "partial" });
await vi.waitFor(() => expect(runtime.chat.messages.at(-1)?.parts).toContainEqual(expect.objectContaining({ text: "partial" })));
controller.error(new AgentStreamDisconnectedError("connection dropped"));
await request;
await vi.waitFor(() => expect(streamSession).toHaveBeenCalledTimes(1));
expect(sendMessages).toHaveBeenCalledTimes(1);
expect(runtime.chat.messages.at(-1)).toEqual(message("answer", "server completed"));
expect(runtime.error).toBeUndefined();
} finally { resources.dispose(); }
});
it("keeps the original response streaming after selecting another conversation", async () => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
@@ -54,8 +129,7 @@ describe("independent conversation runtimes", () => {
streamSession: vi.fn(async (_id, options) => {
options.onEvent({ type: "state", sessionId: "a", messages: [message("b", "new"), message("c")], isStreaming: true });
options.onEvent({ type: "frontend_action", sessionId: "a", data: {
version: "frontend-action@1", sessionId: "a", actionId: "action-a", toolCallId: "call-a", name: "locate_features",
params: {}, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
version: "frontend-action@2", sessionId: "a", actionId: "action-a", toolCallId: "call-a", command: { action: "locate_features", layer: "scada", feature_ids: ["D1"] }, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
} });
options.onEvent({ type: "done", sessionId: "a", data: {} });
}),
@@ -74,3 +148,24 @@ describe("independent conversation runtimes", () => {
resources.dispose();
});
});
it.each([
[JSON.stringify({ code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" }), "waiting_input"],
["Connection refused", "error"],
[JSON.stringify({ code: "OTHER_FAILURE" }), "error"]
] as const)("keeps a pending question distinct from transport failure: %s", async (message, expected) => {
const resources = new WorkbenchSessionResources();
const transport: ChatTransport<AgentUiMessage> = {
sendMessages: async () => { throw new Error(message); },
reconnectToStream: async () => null
};
const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, transport);
resources.activeAgent = runtime;
const onError = vi.fn();
runtime.handlers = { onData: vi.fn(), onEvent: vi.fn(), onError, onFinish: vi.fn() };
try {
await runtime.chat.sendMessage({ text: "定位到公司上报地址" });
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message }));
expect(runtime.runStatus).toBe(expected);
} finally { resources.dispose(); }
});
@@ -157,7 +157,7 @@ async function rejectBackgroundAction(resources: WorkbenchSessionResources, runt
if (!resources.disposed) showMapNotice({ id: `agent-background-display-${runtime.key}`, tone: "info",
title: "后台对话需要展示结果", message: `回到“${runtime.title}”后重新请求地图或工作区展示。` });
await runtime.client.submitFrontendActionResult(request.sessionId, request.actionId, {
version: "frontend-action-result@1", actionId: request.actionId, status: "failed",
version: "frontend-action-result@2", actionId: request.actionId, status: "failed",
error: { code: "SESSION_NOT_ACTIVE", message: "返回对应会话后重新请求地图或工作区操作" }, completedAt: Date.now(),
}).catch(() => { runtime.error = "后台展示操作回执失败"; });
}
@@ -158,3 +158,44 @@ describe("live investigation presentation following", () => {
} finally { oldDisplay.resolve(); test.follower.stop(); }
});
});
it("buffers live events while saved history is loading and drains only after that boundary", async () => {
const ready = deferred<void>();
const loadHistory = vi.fn(async () => ready.promise);
const test = setup({ loadHistory });
test.follower.start(); test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
test.channel.publish({ type: "events", events: [event(11, openChart)] });
expect(test.execute).not.toHaveBeenCalled();
ready.resolve();
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
} finally { test.follower.stop(); }
});
it("loads saved history without live following and aborts an unfinished history load on stop", async () => {
const ready = deferred<void>();
const loadHistory = vi.fn(async (_cursor: InvestigationPresentationCursor, _signal: AbortSignal) => ready.promise);
const subscribe = vi.fn();
const test = setup({ follow: false, loadHistory, subscribe });
test.follower.start();
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
expect(subscribe).not.toHaveBeenCalled();
test.follower.stop();
expect(loadHistory.mock.calls[0][1].aborted).toBe(true);
ready.resolve();
expect(test.execute).not.toHaveBeenCalled();
});
it("reads saved history even before the live event stream opens", async () => {
const loadHistory = vi.fn(async () => undefined);
const test = setup({ loadHistory });
test.follower.start();
try {
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("history"));
expect(test.execute).not.toHaveBeenCalled();
} finally { test.follower.stop(); }
});
@@ -7,22 +7,35 @@ import {
import type { WorkbenchEvent } from "../api/workbench-sync-client";
import type { WorkbenchStreamNotification } from "./workbench-event-channel";
export type InvestigationIdentity = Pick<InvestigationPresentationCursor, "task_id" | "execution_generation" | "active_run">;
export type InvestigationIdentity = Pick<
InvestigationPresentationCursor,
"task_id" | "execution_generation" | "active_run"
>;
type Options = {
taskId: string;
subscribe: (listener: (notification: WorkbenchStreamNotification) => void) => () => void;
fetchCursor: (signal: AbortSignal) => Promise<InvestigationPresentationCursor>;
fetchIdentity: (signal: AbortSignal) => Promise<InvestigationIdentity>;
execute: (command: InvestigationPresentationCommand, signal: AbortSignal) => Promise<void>;
execute: (
command: InvestigationPresentationCommand,
signal: AbortSignal,
event: InvestigationPresentationEvent & { event_seq: number }
) => Promise<void>;
loadHistory?: (cursor: InvestigationPresentationCursor, signal: AbortSignal) => Promise<void>;
follow?: boolean;
clear: () => void;
stopPulse: () => void;
onStatus: (status: "connecting" | "live" | "disconnected" | "error", error?: string) => void;
onStatus: (
status: "connecting" | "live" | "history" | "disconnected" | "error",
error?: string
) => void;
};
type Pending = { sequence: number; event: InvestigationPresentationEvent };
/** No replay: a fresh authorized baseline is taken after every connection, with the listener already attached. */
/** Bind a read-only history snapshot before draining later live events; subscribing first prevents gaps. */
export class InvestigationPresentationFollower {
private unsubscribe?: () => void;
private streamConnected = false;
private binding: AbortController | null = null;
private work = new AbortController();
private identity: InvestigationIdentity | null = null;
@@ -35,39 +48,72 @@ export class InvestigationPresentationFollower {
constructor(private options: Options) {}
start() { this.unsubscribe = this.options.subscribe((notification) => this.receive(notification)); }
start() {
if (this.options.follow === false) void this.connect();
else {
this.unsubscribe = this.options.subscribe((notification) => this.receive(notification));
// Saved results remain readable when the SSE connection is not available.
if (this.options.loadHistory && !this.binding) void this.connect();
}
}
stop() {
this.streamConnected = false;
this.unsubscribe?.();
this.unsubscribe = undefined;
this.disconnect();
}
discardPendingMapCommands() {
this.pending = this.pending.filter(({ event }) =>
event.command.action === "open_chart" || event.command.action === "close_chart"
this.pending = this.pending.filter(
({ event }) => event.command.action === "open_chart" || event.command.action === "close_chart"
);
}
updateIdentity(identity: InvestigationIdentity) {
if (identity.task_id !== this.options.taskId || this.identity && identity.execution_generation < this.identity.execution_generation) return;
const changed = this.identity && (identity.execution_generation !== this.identity.execution_generation
|| identity.active_run && this.identity.active_run && identity.active_run.run_id !== this.identity.active_run.run_id);
if (
identity.task_id !== this.options.taskId ||
(this.identity && identity.execution_generation < this.identity.execution_generation)
)
return;
const changed =
this.identity &&
(identity.execution_generation !== this.identity.execution_generation ||
(identity.active_run &&
this.identity.active_run &&
identity.active_run.run_id !== this.identity.active_run.run_id));
if (changed) {
this.work.abort();
this.work = new AbortController();
this.options.clear();
}
// A finished run may no longer be active; retain its identity to finish already received close commands.
this.identity = identity.active_run || !this.identity || identity.execution_generation !== this.identity.execution_generation
? identity : { ...identity, active_run: this.identity.active_run ? { ...this.identity.active_run, status: "succeeded" } : null };
this.identity =
identity.active_run ||
!this.identity ||
identity.execution_generation !== this.identity.execution_generation
? identity
: {
...identity,
active_run: this.identity.active_run
? { ...this.identity.active_run, status: "succeeded" }
: null
};
if (!identity.active_run || identity.active_run.status !== "running") this.options.stopPulse();
void this.drain();
}
private receive(notification: WorkbenchStreamNotification) {
if (notification.type === "open") { void this.connect(); return; }
if (notification.type !== "events") { this.disconnect(); return; }
if (notification.type === "open") {
this.streamConnected = true;
void this.connect();
return;
}
if (notification.type !== "events") {
this.streamConnected = false;
this.disconnect();
return;
}
if (!this.binding) return;
for (const event of notification.events) this.add(event);
void this.drain();
@@ -75,11 +121,27 @@ export class InvestigationPresentationFollower {
private add(event: WorkbenchEvent) {
if (!this.binding) return;
if (event.topic !== "operational.presentation.requested" || event.metadata.task_id !== this.options.taskId) return;
if (event.event_seq <= (this.baseline ?? -1) || event.event_seq <= this.lastSeen || this.pending.some((item) => item.sequence === event.event_seq)) return;
if (
event.topic !== "operational.presentation.requested" ||
event.metadata.task_id !== this.options.taskId
)
return;
if (
event.event_seq <= (this.baseline ?? -1) ||
event.event_seq <= this.lastSeen ||
this.pending.some((item) => item.sequence === event.event_seq)
)
return;
const parsed = investigationPresentationEventSchema.safeParse(event.metadata);
if (!parsed.success) { this.options.onStatus("error", "调查展示参数未通过校验"); return; }
if (this.pending.length >= 1000) { this.disconnect(); this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随"); return; }
if (!parsed.success) {
this.options.onStatus("error", "调查展示参数未通过校验");
return;
}
if (this.pending.length >= 1000) {
this.disconnect();
this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随");
return;
}
this.pending.push({ sequence: event.event_seq, event: parsed.data });
this.pending.sort((a, b) => a.sequence - b.sequence);
}
@@ -97,11 +159,18 @@ export class InvestigationPresentationFollower {
this.baseline = cursor.event_cursor;
this.lastSeen = cursor.event_cursor;
this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor);
await this.options.loadHistory?.(cursor, controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
this.updateIdentity(cursor);
this.options.onStatus("live");
this.options.onStatus(
this.streamConnected && this.options.follow !== false ? "live" : "history"
);
void this.drain();
} catch (error) {
if (!controller.signal.aborted) { this.disconnect(); this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败"); }
if (!controller.signal.aborted) {
this.disconnect();
this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败");
}
}
}
@@ -129,7 +198,11 @@ export class InvestigationPresentationFollower {
const identity = await this.options.fetchIdentity(binding.signal);
if (!binding.signal.aborted) this.updateIdentity(identity);
} catch (error) {
if (!binding.signal.aborted) this.options.onStatus("error", error instanceof Error ? error.message : "调查运行状态核验失败");
if (!binding.signal.aborted)
this.options.onStatus(
"error",
error instanceof Error ? error.message : "调查运行状态核验失败"
);
}
}
@@ -140,24 +213,41 @@ export class InvestigationPresentationFollower {
try {
while (this.pending.length && epoch === this.epoch) {
const next = this.pending[0];
if (next.sequence <= this.lastSeen || next.event.execution_generation < this.identity!.execution_generation) { this.pending.shift(); continue; }
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) {
if (
next.sequence <= this.lastSeen ||
next.event.execution_generation < this.identity!.execution_generation
) {
this.pending.shift();
continue;
}
if (
next.event.execution_generation !== this.identity!.execution_generation ||
next.event.run_id !== this.identity!.active_run?.run_id
) {
await this.resolveIdentity(next.event);
if (epoch !== this.epoch) break;
if (this.pending[0] !== next) continue;
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) break;
if (
next.event.execution_generation !== this.identity!.execution_generation ||
next.event.run_id !== this.identity!.active_run?.run_id
)
break;
}
this.pending.shift();
this.lastSeen = next.sequence;
const signal = this.work.signal;
try {
const command = next.event.command.action === "highlight_features" && this.identity!.active_run?.status !== "running"
? { ...next.event.command, effect: "steady" as const } : next.event.command;
await this.options.execute(command, signal);
const command =
next.event.command.action === "highlight_features" &&
this.identity!.active_run?.status !== "running"
? { ...next.event.command, effect: "steady" as const }
: next.event.command;
await this.options.execute(command, signal, { ...next.event, event_seq: next.sequence });
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("live");
if (this.identity!.active_run?.status !== "running") this.options.stopPulse();
} catch (error) {
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败");
if (!signal.aborted && epoch === this.epoch)
this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败");
}
}
} finally {
@@ -28,7 +28,67 @@ describe("recent activities for Agent suggestions", () => {
describe("recommendation cache lifecycle", () => {
let root: Root | undefined;
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.restoreAllMocks(); });
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
it("retries failures after cache expiry with a bounded budget and supports manual retry", async () => {
vi.useFakeTimers();
root = createRoot(document.createElement("div"));
const request = vi.fn<Parameters<typeof useAgentPromptSuggestions>[0]["request"]>().mockRejectedValue(new Error("unavailable"));
const empty: never[] = [];
let result: ReturnType<typeof useAgentPromptSuggestions>;
function Probe() {
result = useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "a", sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe))));
expect(result!.unavailable).toBe(true);
expect(request).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(request).toHaveBeenCalledTimes(2);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(request).toHaveBeenCalledTimes(3);
await act(async () => { await vi.advanceTimersByTimeAsync(300_000); });
expect(request).toHaveBeenCalledTimes(3);
request.mockResolvedValueOnce([{ question: "核查压力数据", activityId: null }]);
await act(async () => result!.retry());
expect(request).toHaveBeenCalledTimes(4);
expect(result!.suggestions[0]?.question).toBe("核查压力数据");
expect(result!.unavailable).toBe(false);
});
it("retries cached failures on service recovery while preserving successful recommendations", async () => {
root = createRoot(document.createElement("div"));
const request = vi.fn<Parameters<typeof useAgentPromptSuggestions>[0]["request"]>()
.mockRejectedValueOnce(new Error("offline")).mockResolvedValue([{ question: "核查压力数据", activityId: null }]);
const empty: never[] = [];
function Probe({ recoveryVersion }: { recoveryVersion: number }) {
useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "a", recoveryVersion, sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
for (const recoveryVersion of [0, 1, 2]) {
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { recoveryVersion }))));
}
expect(request).toHaveBeenCalledTimes(2);
});
it("waits for a model and cancels automatic retries when the panel is disabled", async () => {
vi.useFakeTimers();
root = createRoot(document.createElement("div"));
const request = vi.fn(async () => { throw new Error("unavailable"); });
const empty: never[] = [];
function Probe({ model, enabled }: { model: string; enabled: boolean }) {
useAgentPromptSuggestions({ enabled, model, cacheScope: "a", sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
const render = async (model: string, enabled: boolean) => act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { model, enabled }))));
await render("", true);
expect(request).not.toHaveBeenCalled();
await render("fixture/model", true);
expect(request).toHaveBeenCalledTimes(1);
await render("fixture/model", false);
await act(async () => { await vi.advanceTimersByTimeAsync(90_000); });
expect(request).toHaveBeenCalledTimes(1);
});
it("reuses recommendations across history refresh, panel reopen and a new empty conversation", async () => {
const container = document.createElement("div");
root = createRoot(container);
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, AgentRecentActivity } from "@/features/agent";
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
import type { AnalysisArtifact } from "../workspace/workspace-model";
@@ -46,11 +46,12 @@ export function collectRecentAgentActivities(input: ActivityInputs, now = Date.n
return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()];
}
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", recoveryVersion = 0, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
enabled: boolean;
model: string;
sessionId: string | null;
cacheScope?: string;
recoveryVersion?: number;
request: AgentApiClient["generatePromptSuggestions"];
}) {
const { cache } = useWorkbenchSessionResources();
@@ -64,13 +65,22 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
const activitySnapshot = useRef(activities);
activitySnapshot.current = activities;
const key = JSON.stringify(["suggestions", cacheScope, model, digest]);
const [retryVersion, setRetryVersion] = useState(0);
const [refreshVersion, setRefreshVersion] = useState(0);
const attempts = useRef({ key, count: 0, recoveryVersion, retryVersion });
const retry = useCallback(() => setRetryVersion((version) => version + 1), []);
const [state, setState] = useState<{ scope: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
scope: cacheScope, suggestions: [], loading: false, unavailable: false,
});
useEffect(() => {
if (!enabled) return;
if (!enabled || !model.trim()) return;
let disposed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const previous = cache.peek<DisplaySuggestion[]>(key);
const recovered = attempts.current.recoveryVersion !== recoveryVersion || attempts.current.retryVersion !== retryVersion;
if (attempts.current.key !== key || recovered) attempts.current = { key, count: 0, recoveryVersion, retryVersion };
const attempt = attempts.current;
const context = activitySnapshot.current;
const apply = (suggestions: DisplaySuggestion[]) => {
if (!disposed) setState({ scope: cacheScope, suggestions, loading: false, unavailable: false });
};
@@ -78,7 +88,7 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
else setState((current) => ({ scope: cacheScope,
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: true, unavailable: false }));
void cache.load(key, async () => {
const context = activitySnapshot.current;
attempt.count++;
const suggestions = await request(context, model, AbortSignal.timeout(60_000));
const sourceById = new Map(context.map((item) => [item.id, item]));
return suggestions.map((item) => {
@@ -87,11 +97,17 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
: "供水任务建议" };
});
}, 10 * 60_000, { errorTtl: 30_000 }).then(apply).catch(() => {
if (!disposed) setState((current) => ({ scope: cacheScope,
}, 10 * 60_000, { errorTtl: 30_000, force: recovered && !!previous?.error }).then(apply).catch(() => {
if (disposed) return;
setState((current) => ({ scope: cacheScope,
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: false, unavailable: true }));
// Cache expiry alone does not rerender the panel. Retry transient failures
// here, with a bounded budget; reopening still reuses the shared cache.
if (attempt.count < 3) {
timer = setTimeout(() => setRefreshVersion((version) => version + 1), Math.max(1, (cache.peek(key)?.expiresAt ?? Date.now() + 30_000) - Date.now()));
}
});
return () => { disposed = true; };
}, [cache, cacheScope, digest, enabled, key, model, request]);
return state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false };
return () => { disposed = true; clearTimeout(timer); };
}, [cache, cacheScope, digest, enabled, key, model, recoveryVersion, refreshVersion, request, retryVersion]);
return { ...(state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false }), retry };
}
@@ -1,40 +1,110 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { executePresentationCommand } from "../map/presentation-command-executor";
import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import type { WorkbenchAccess } from "../api/request-context";
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
import {
fetchOperationalTask,
fetchOperationalTaskPresentationCursor,
fetchOperationalTaskPresentationHistory,
type OperationalTask
} from "../api/operational-task-client";
import type { InvestigationMapTarget } from "../api/investigation-report";
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
import {
presentationEventKey,
type InvestigationPresentationEvent
} from "../api/investigation-presentation";
import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive";
import { presentationCommandLabel } from "../utils/presentation-command-label";
import type {
InvestigationPresentationCommand
} from "../api/investigation-presentation";
import { InvestigationMapPresentation } from "../map/investigation-map-presentation";
import type { WorkbenchMapController } from "../map/workbench-map-controller";
import type { WorkbenchSyncState } from "./use-workbench-sync";
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
import {
type InvestigationDevPreview,
type InvestigationPresentationActivity
} from "../utils/investigation-dev-preview";
import {
PresentationViewHistory,
emptyPresentationView,
type PresentationView
} from "../utils/presentation-view-history";
import {
capturePresentationCamera,
fitPresentationCamera,
presentationHistoryPorts,
renderPresentationSnapshot
} from "../map/presentation-history-ports";
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
import {
createInvestigationDevCase,
type InvestigationDevCaseId
} from "../utils/investigation-dev-cases";
export type InvestigationChart = Extract<
InvestigationPresentationCommand,
{ action: "open_chart" }
>;
type Options = {
enabled: boolean; task: OperationalTask | null; projectId: string | null;
sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess;
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
scope?: "investigation" | "chat" | "preview";
scopeId?: string | null;
enabled: boolean;
task: OperationalTask | null;
projectId: string | null;
sync: WorkbenchSyncState;
getAccessToken?: WorkbenchAccess;
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
controller: WorkbenchMapController;
getPadding: () => PaddingOptions;
onClearLayers: () => void;
};
function frame(signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
signal.throwIfAborted();
const abort = () => { cancelAnimationFrame(id); reject(signal.reason); };
const id = requestAnimationFrame(() => { signal.removeEventListener("abort", abort); resolve(); });
const abort = () => {
cancelAnimationFrame(id);
reject(signal.reason);
};
const id = requestAnimationFrame(() => {
signal.removeEventListener("abort", abort);
resolve();
});
signal.addEventListener("abort", abort, { once: true });
});
}
/** Separate commands need a rendered frame; an open and close in one SSE batch must not collapse into one React update. */
async function waitForChart(chartId: string, signal: AbortSignal) {
async function waitForChart(
chartId: string,
signal: AbortSignal,
scope: string,
rendering = () => true
) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
await frame(signal);
const element = [...document.querySelectorAll<HTMLElement>("[data-investigation-chart]")].find((item) => item.dataset.investigationChart === chartId);
if (!rendering()) return;
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
// A deliberate collapse defers rendering, rather than failing a valid command.
if (panel?.dataset.investigationCollapsed === "true") return;
const element = [
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
].find(
(item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]')
);
if (element && element.getBoundingClientRect().height > 0) {
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]");
if (viewport) {
const plot = element.querySelector("svg[role=img]") ?? element;
const overflow =
plot.getBoundingClientRect().bottom - viewport.getBoundingClientRect().bottom + 12;
if (overflow > 0) viewport.scrollTop += overflow;
}
const visibleUntil = Date.now() + 500;
while (Date.now() < visibleUntil) await frame(signal);
return;
@@ -46,123 +116,484 @@ async function waitForChart(chartId: string, signal: AbortSignal) {
export function useInvestigationPresentation(options: Options) {
const valuesRef = useRef(options);
valuesRef.current = options;
const [charts, setCharts] = useState<InvestigationChart[]>([]);
const [overlay, setOverlay] = useState<InvestigationOverlay | null>(null);
const storeRef = useRef(new PresentationViewHistory());
const [, refresh] = useState(0);
const publish = useCallback(() => refresh((value) => value + 1), []);
const [localPreview, setLocalPreview] = useState(false);
const localPreviewRef = useRef(false);
const [cameraPaused, setCameraPaused] = useState(false);
const cameraPausedRef = useRef(false);
const [status, setStatus] = useState<"connecting" | "live" | "disconnected" | "error">("disconnected");
const [status, setStatus] = useState<
"connecting" | "live" | "history" | "disconnected" | "error"
>("disconnected");
const [error, setError] = useState<string | null>(null);
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
const activityIdRef = useRef(0);
const previewRef = useRef<AbortController | null>(null);
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
const manualRequestRef = useRef<AbortController | null>(null);
const mapCommandsRef = useRef(new AbortController());
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
const scene = useMemo(
() =>
new InvestigationMapPresentation(
() => options.mapRef.current,
() => valuesRef.current.getPadding(),
options.scope
),
[options.mapRef, options.scope]
);
const taskId = options.task?.task_id ?? null;
const projectRef = useRef(options.projectId);
const subscribe = options.sync.subscribePresentationEvents;
const source = useCallback(
(): InvestigationPresentationActivity["source"] =>
valuesRef.current.scope === "chat"
? "chat"
: valuesRef.current.scope === "preview"
? "preview"
: "live",
[]
);
const update = useCallback(
(updater: (view: PresentationView) => PresentationView) => {
storeRef.current.update(updater(storeRef.current.current));
publish();
},
[publish]
);
const record = useCallback(
(label: string, origin = source()) => {
storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, {
taskId: valuesRef.current.task?.task_id
});
publish();
},
[publish, source]
);
const selectHistory = useCallback(
(id: number | null) => {
const store = storeRef.current;
if (store.selectedId === null)
store.update({
...store.current,
camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
});
if (!store.select(id === store.currentEntryId ? null : id)) return;
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
},
[publish, scene]
);
const clearMapOverlays = useCallback((discardPending = true) => {
mapCommandsRef.current.abort();
mapCommandsRef.current = new AbortController();
manualRequestRef.current?.abort();
if (discardPending) followerRef.current?.discardPendingMapCommands();
scene.clear(); setOverlay(null); setHighlightLayers([]);
}, [scene]);
const clearMapOverlays = useCallback(
(discardPending = true) => {
mapCommandsRef.current.abort();
mapCommandsRef.current = new AbortController();
manualRequestRef.current?.abort();
previewRef.current?.abort();
if (discardPending) followerRef.current?.discardPendingMapCommands();
storeRef.current.select(null);
scene.clear();
update((view) => ({
...view,
highlights: { type: "FeatureCollection", features: [] },
highlightLayers: [],
overlay: null,
scada: null
}));
},
[scene, update]
);
const clear = useCallback(() => {
// New task generations retain their first queued map command.
clearMapOverlays(false); setCharts([]);
localPreviewRef.current = false;
setLocalPreview(false);
clearMapOverlays(false);
update(() => ({
...emptyPresentationView(),
camera: capturePresentationCamera(valuesRef.current.mapRef.current)
}));
valuesRef.current.onClearLayers();
}, [clearMapOverlays]);
}, [clearMapOverlays, update]);
const executeCommand = useCallback(
async (
command: InvestigationPresentationCommand,
signal: AbortSignal,
origin: InvestigationPresentationActivity["source"],
event?: InvestigationPresentationEvent & { event_seq: number }
) => {
if (command.action !== "open_chart" && command.action !== "close_chart")
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
signal.throwIfAborted();
const store = storeRef.current;
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
return { recorded: true, duplicate: true };
update((view) => ({
...view,
evidence:
valuesRef.current.task?.evidence.map(({ evidence_id, summary }) => ({
evidence_id,
summary
})) ?? [],
camera:
store.selectedId === null && (!view.camera || cameraPausedRef.current)
? (capturePresentationCamera(valuesRef.current.mapRef.current) ?? view.camera)
: view.camera
}));
// Cleanup changes current but does not create a selectable historical result.
const historyId = command.action.startsWith("clear_")
? null
: store.begin(
presentationCommandLabel(command, store.current),
origin,
valuesRef.current.task?.title ?? null,
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
);
const id = ++activityIdRef.current;
setActivity((current) => [
...current,
{ id, action: command.action, source: origin, status: "running" }
]);
let actionSnapshot = store.current;
const apply = (updater: (view: PresentationView) => PresentationView) => {
update(updater);
actionSnapshot = store.current;
};
try {
const result = await executePresentationCommand(
command,
signal,
presentationHistoryPorts({
controller: valuesRef.current.controller,
scene,
map: () => valuesRef.current.mapRef.current,
padding: () => valuesRef.current.getPadding(),
update: apply,
rendering: () => store.selectedId === null,
cameraAllowed: () => !cameraPausedRef.current,
waitForChart: (chartId, nextSignal) =>
waitForChart(
chartId,
nextSignal,
valuesRef.current.scope ?? "investigation",
() => store.selectedId === null
),
frame
})
);
signal.throwIfAborted();
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
);
return store.selectedId === null
? result
: {
...result,
displayed: false,
fitted: false,
recorded: historyId !== null,
viewing_history: true
};
} catch (cause) {
const message = signal.aborted
? "展示已取消"
: cause instanceof Error
? cause.message
: "展示失败";
if (historyId !== null)
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
);
throw cause;
}
},
[scene, publish, update]
);
useEffect(() => {
if (projectRef.current !== options.projectId) {
projectRef.current = options.projectId;
storeRef.current.reset();
setActivity([]);
}
clear();
}, [clear, taskId, options.projectId, options.task?.execution_generation, options.scopeId]);
useEffect(() => {
manualRequestRef.current?.abort();
cameraPausedRef.current = false;
setCameraPaused(false);
if (!options.enabled || !taskId) { clear(); return; }
if (!taskId || (options.scope && options.scope !== "investigation") || !options.mapReady) {
if (!localPreviewRef.current) clear();
return;
}
const follower = new InvestigationPresentationFollower({
taskId, subscribe,
fetchCursor: (signal) => fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
taskId,
follow: options.enabled,
loadHistory: async (cursor, signal) => {
const task = valuesRef.current.task;
if (!task || task.task_id !== taskId) return;
const events = await fetchOperationalTaskPresentationHistory(
taskId,
cursor.event_cursor,
valuesRef.current.getAccessToken,
signal
);
signal.throwIfAborted();
const next = await hydrateInvestigationPresentationArchive({
events,
task,
history: storeRef.current,
signal,
createPorts: (staging) =>
presentationHistoryPorts({
controller: valuesRef.current.controller,
scene,
map: () => valuesRef.current.mapRef.current,
padding: () => valuesRef.current.getPadding(),
update: (updater) => staging.update(updater(staging.current)),
rendering: () => false,
cameraAllowed: () => true,
waitForChart: async () => undefined,
frame: async (nextSignal) => nextSignal.throwIfAborted()
})
});
signal.throwIfAborted();
storeRef.current.update(next);
publish();
const map = valuesRef.current.mapRef.current;
if (map && storeRef.current.selectedId === null)
renderPresentationSnapshot(scene, map, next);
await frame(signal);
},
subscribe,
fetchCursor: (signal) =>
fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
fetchIdentity: (signal) => fetchOperationalTask(taskId, options.getAccessToken, signal),
clear,
stopPulse: () => scene.stopPulse(),
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
execute: async (command, signal) => {
const sourceSignal = signal;
if (command.action !== "open_chart" && command.action !== "close_chart") {
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
}
signal.throwIfAborted();
try {
if (command.action === "highlight_features") {
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
signal.throwIfAborted();
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
setHighlightLayers([command.layer]);
} else if (command.action === "clear_highlights") {
scene.clearHighlights(); setHighlightLayers([]);
} else if (command.action === "open_chart") {
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
await waitForChart(command.chart_id, signal);
} else if (command.action === "close_chart") {
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
await frame(signal);
} else if (command.action === "set_overlay") {
const next = { ...command, visible: true };
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
} else {
scene.clearOverlay(command.overlay_id);
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
}
} catch (cause) {
if (signal.aborted && !sourceSignal.aborted) return;
throw cause;
}
onStatus: (next, message) => {
setStatus(!valuesRef.current.enabled && next === "live" ? "history" : next);
setError(message ?? null);
},
execute: async (command, signal, event) => {
await executeCommand(command, signal, "live", event);
}
});
followerRef.current = follower;
follower.start();
return () => { follower.stop(); if (followerRef.current === follower) followerRef.current = null; };
}, [clear, options.enabled, options.getAccessToken, options.projectId, scene, subscribe, taskId]);
return () => {
follower.stop();
if (followerRef.current === follower) followerRef.current = null;
};
}, [
clear,
executeCommand,
options.enabled,
options.mapReady,
options.scope,
options.task?.execution_generation,
options.getAccessToken,
options.projectId,
publish,
scene,
subscribe,
taskId
]);
useEffect(
() => () => {
previewRef.current?.abort();
},
[taskId, options.projectId, options.task?.execution_generation, options.scopeId]
);
useEffect(() => {
if (options.task) followerRef.current?.updateIdentity(options.task);
}, [options.task]);
const pauseCamera = useCallback(() => {
cameraPausedRef.current = true; setCameraPaused(true);
if (storeRef.current.selectedId !== null) return;
cameraPausedRef.current = true;
setCameraPaused(true);
}, []);
useEffect(() => {
const map = options.mapRef.current;
if (!map || !options.mapReady || !options.enabled) return;
if (
!map ||
!options.mapReady ||
(!options.enabled && !localPreview && options.scope !== "chat")
)
return;
const canvas = map.getCanvasContainer();
// Canvas input excludes programmatic fitBounds/easeTo and also covers touch/keyboard navigation.
canvas.addEventListener("pointerdown", pauseCamera);
canvas.addEventListener("wheel", pauseCamera, { passive: true });
canvas.addEventListener("keydown", pauseCamera);
return () => { canvas.removeEventListener("pointerdown", pauseCamera); canvas.removeEventListener("wheel", pauseCamera); canvas.removeEventListener("keydown", pauseCamera); };
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera]);
useEffect(() => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [scene]);
return () => {
canvas.removeEventListener("pointerdown", pauseCamera);
canvas.removeEventListener("wheel", pauseCamera);
canvas.removeEventListener("keydown", pauseCamera);
};
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera, localPreview, options.scope]);
useEffect(
() => () => {
manualRequestRef.current?.abort();
scene.destroy();
},
[scene]
);
const locateTargets = useCallback(async (targets: InvestigationMapTarget[]) => {
manualRequestRef.current?.abort();
const controller = new AbortController(); manualRequestRef.current = controller;
pauseCamera();
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
for (const target of targets) groups.set(target.source_id, [...groups.get(target.source_id) ?? [], target.feature_id]);
const collections = await Promise.all([...groups].map(([source, ids]) => valuesRef.current.controller.queryPresentationFeatures(source, ids, controller.signal)));
controller.signal.throwIfAborted();
const data: FeatureCollection = { type: "FeatureCollection", features: collections.flatMap((collection) => collection.features) };
if (!data.features.length) throw new Error("未找到关联地图要素");
scene.highlight(data, false, true); setHighlightLayers([...groups.keys()]);
}, [pauseCamera, scene]);
const locateTargets = useCallback(
async (targets: InvestigationMapTarget[]) => {
if (storeRef.current.selectedId !== null)
throw new Error("请先回到当前,再执行新的要素定位。");
manualRequestRef.current?.abort();
const controller = new AbortController();
manualRequestRef.current = controller;
pauseCamera();
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
for (const target of targets)
groups.set(target.source_id, [...(groups.get(target.source_id) ?? []), target.feature_id]);
const collections = await Promise.all(
[...groups].map(([layer, ids]) =>
valuesRef.current.controller.queryPresentationFeatures(layer, ids, controller.signal)
)
);
controller.signal.throwIfAborted();
const data: FeatureCollection = {
type: "FeatureCollection",
features: collections.flatMap((collection) => collection.features)
};
if (!data.features.length) throw new Error("未找到关联地图要素");
if (storeRef.current.selectedId === null) scene.highlight(data, false, true);
update((view) => ({
...view,
highlights: structuredClone(data),
highlightLayers: [...groups.keys()],
camera: fitPresentationCamera(
valuesRef.current.mapRef.current,
data,
valuesRef.current.getPadding()
)
}));
record("定位关联要素");
},
[pauseCamera, scene, update, record]
);
const runPreview = useCallback(
async (
input: InvestigationDevPreview | InvestigationPresentationCommand[],
label = "本地结果"
) => {
const preview = Array.isArray(input) ? { commands: input, blocks: [], records: [] } : input;
if (preview.commands.length > 0 && !valuesRef.current.mapReady)
throw new Error("请等待地图就绪后执行展示指令。");
localPreviewRef.current = true;
setLocalPreview(true);
previewRef.current?.abort();
const controller = new AbortController();
previewRef.current = controller;
update((view) => ({
...view,
blocks: structuredClone(preview.blocks),
records: structuredClone(preview.records)
}));
if (preview.blocks.length || preview.records.length) record(`展示结果 · ${label}`, "preview");
for (const command of preview.commands) {
controller.signal.throwIfAborted();
await executeCommand(command, controller.signal, "preview");
}
},
[executeCommand, update, record]
);
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
const map = valuesRef.current.mapRef.current;
if (!map) return "";
const task = valuesRef.current.task;
const progress =
task?.latest_progress?.execution_generation === task?.execution_generation
? task?.latest_progress
: null;
const target = progress?.progress.diagram?.node_refs.flatMap(
(node) => node.map_targets ?? []
)[0];
return createInvestigationDevCase(
caseId,
map.getCenter().toArray() as [number, number],
target
);
}, []);
const taskEntries =
!options.scope || options.scope === "investigation"
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
: storeRef.current.entries;
const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null;
return {
charts, overlay, cameraPaused, status, error, highlightLayers, clearMapOverlays,
charts: visible.charts,
blocks: visible.blocks,
records: visible.records,
overlay: visible.overlay,
evidence: visible.evidence,
highlightLayers: visible.highlightLayers,
cameraPaused: reviewing ? false : cameraPaused,
status,
error,
activity,
localPreview,
hasHistory: taskEntries.length > 0,
history: {
currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries,
selectedId: storeRef.current.selectedId,
onSelect: selectHistory
},
executeCommand,
runPreview,
createSample,
clearPreview: clear,
clearMapOverlays,
pauseCamera,
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
resumeCamera: () => {
cameraPausedRef.current = false;
setCameraPaused(false);
},
locateTargets,
closeChart: (id: string) => setCharts((current) => current.filter((item) => item.chart_id !== id)),
toggleOverlay: () => { if (overlay) { scene.showOverlay(!overlay.visible); setOverlay({ ...overlay, visible: !overlay.visible }); } },
fitOverlay: () => { pauseCamera(); scene.fitOverlay(); }
closeChart: (id: string) => {
if (!reviewing)
void executeCommand(
{ action: "close_chart", chart_id: id },
new AbortController().signal,
source()
).catch(() => undefined);
},
toggleOverlay: () => {
if (reviewing || !visible.overlay) return;
const next = { ...visible.overlay, visible: !visible.overlay.visible };
scene.showOverlay(next.visible);
update((view) => ({ ...view, overlay: next }));
record(next.visible ? "显示覆盖层" : "隐藏覆盖层");
},
fitOverlay: () => {
pauseCamera();
scene.fitOverlay();
if (!reviewing && visible.overlay) {
update((view) => ({
...view,
camera: fitPresentationCamera(
valuesRef.current.mapRef.current,
visible.overlay!.data,
valuesRef.current.getPadding()
)
}));
record("定位覆盖层");
}
}
};
}
@@ -78,7 +78,7 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
scenario_code: null,
feedback_requirements: [],
activity: [],
origin: "synthetic",
origin: "system_data",
updated_at: "2026-09-23T09:00:00Z"
}
};
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WorkbenchAccess } from "../api/request-context";
import {
acknowledgeOperationalTaskNotification,
answerOperationalTaskQuestion,
type TaskQuestionAnswer,
createOperationalRequestId,
createOperationalTask,
decideOperationalTaskAction,
@@ -317,6 +319,13 @@ export function useOperationalTasks(
}
}, [notifications, acknowledgeNotification]);
const answerQuestion = useCallback(async (taskId: string, questionId: string, version: number,
answers: TaskQuestionAnswer[], requestId: string) => {
const updated = await answerOperationalTaskQuestion(taskId, questionId, version, answers, requestId, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? updated : item));
return updated;
}, [getAccessToken]);
return {
error: error ?? sync.error,
loading,
@@ -325,6 +334,7 @@ export function useOperationalTasks(
notifications,
refresh,
createInvestigation,
answerQuestion,
decideAction,
executeAction,
reconcileAction,
@@ -13,6 +13,7 @@ import {
useInteractionRequests,
useAgentConnection,
FrontendActionExecutor,
AnswerViewStateScope,
agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext,
agentBootstrapKey,
@@ -103,6 +104,12 @@ export function useWorkbenchAgent({
const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`;
const scopeKey = projectScopeKey;
const { connectionStatus, recheck: checkAgentConnection, recoveryVersion } = useAgentConnection(scopeClient, scopeKey);
const answerStateScope = useMemo(() => new AnswerViewStateScope(`${resources.id}:${scopeKey}`, scopeClient), [resources.id, scopeClient, scopeKey]);
useEffect(() => {
answerStateScope.retain();
resources.disposers.add(answerStateScope.dispose);
return () => { resources.disposers.delete(answerStateScope.dispose); answerStateScope.release(); };
}, [answerStateScope, resources]);
const history = useAgentHistory(scopeClient, scopeKey);
const topics = useAgentTopics(scopeClient, scopeKey);
const runtimeSummaries: AgentChatSessionSummary[] = agentRuntimes(resources).filter((entry) => entry.scope === scopeKey && entry.sessionId && entry.purpose !== "task_execution")
@@ -184,7 +191,7 @@ export function useWorkbenchAgent({
try {
await target.chat.sendMessage(alreadyPresent ? undefined : replyMessage, { body: {
session_id: owningSessionId, interaction_id: interactionId, model: selectedModel || undefined,
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@1"] : ["host-question@1"],
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@2"] : ["host-question@1"],
approval_mode: approvalMode, task_context: target.taskDiscussion?.context,
} });
} catch (error) { target.runStatus = "error"; throw error; }
@@ -344,8 +351,14 @@ export function useWorkbenchAgent({
[refreshInteractionRequests, refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
const synchronizeModelCatalog = useCallback((catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => {
// A background request from an old project must not replace this project's controls.
if (resources.disposed || resources.activeAgent?.scope !== scopeKey) return;
setModelOptions(catalog.models);
setSelectedModel((current) => resolveAgentModelSelection(catalog, current));
}, [resources, scopeKey]);
const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken,
getProjectId), [getAccessToken, getProjectId, scopeClient]);
getProjectId, synchronizeModelCatalog), [getAccessToken, getProjectId, scopeClient, synchronizeModelCatalog]);
const defaultRuntime = useMemo(() => {
const created = createAgentRuntime(resources, scopeKey, scopeClient, transport);
return created;
@@ -768,7 +781,7 @@ export function useWorkbenchAgent({
const loaded = await projectClient.loadSession(id, { messageLimit: 40 });
if (!loaded) throw new Error("任务协作会话未能打开,请重试。");
target = createAgentRuntime(resources, projectScopeKey, projectClient,
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId), loaded);
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog), loaded);
if (loaded.isStreaming || loaded.runStatus === "running") resumeAgentRuntime(resources, target);
}
const titleBeforeOpen = titlesBeforeOpen.get(target);
@@ -781,7 +794,7 @@ export function useWorkbenchAgent({
activateRuntime(target);
setPromptFocusRequest((current) => current + 1);
return id;
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]);
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
const openTaskExecutionHistory = useCallback(async (taskId: string, runId: string, executionSessionId: string) => {
const normalizedTaskId = taskId.trim();
@@ -809,7 +822,7 @@ export function useWorkbenchAgent({
resources,
projectScopeKey,
projectClient,
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId),
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog),
loaded
);
} else {
@@ -833,7 +846,7 @@ export function useWorkbenchAgent({
activateRuntime(target);
setStatusLabel(loaded.runStatus === "running" ? "调查执行记录自动同步中" : "调查执行记录已打开");
return loaded.sessionId;
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]);
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
useEffect(() => {
const target = runtimeRef.current;
@@ -1084,7 +1097,7 @@ export function useWorkbenchAgent({
topic_id: !activeSessionId ? runtimeRef.current?.topicId ?? undefined : undefined,
model: selectedModel || undefined,
approval_mode: approvalMode,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
capabilities: frontendActionEnabledRef.current ? ["frontend-action@2", "host-question@1"] : ["host-question@1"],
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
}
}
@@ -1108,7 +1121,7 @@ export function useWorkbenchAgent({
setStatusLabel("Agent 正在调查水质投诉事件");
void target.chat.sendMessage({ text: prompt }, { body: {
session_id: id, model: selectedModel || undefined, approval_mode: approvalMode, trigger_context: trigger,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
capabilities: frontendActionEnabledRef.current ? ["frontend-action@2", "host-question@1"] : ["host-question@1"],
} }).catch(() => {});
return { sessionId: id, reused: reserved.event_duplicate === true };
}, [activateRuntime, approvalMode, resources, scopeClient, scopeKey, selectedModel, transport]);
@@ -1185,6 +1198,7 @@ export function useWorkbenchAgent({
return {
apiClient: clientRef.current,
answerStateScope,
contentReference: runtime.reference,
setContentReference: (value: import("@/features/agent").AnswerUiSelection | undefined) => {
const active = resources.activeAgent;
@@ -1313,7 +1327,7 @@ function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function createWorkbenchAgentTransport(client: AgentApiClient, access?: WorkbenchAccess, getProjectId?: () => Promise<string>) {
function createWorkbenchAgentTransport(client: AgentApiClient, access?: WorkbenchAccess, getProjectId?: () => Promise<string>, onModels?: (catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => void) {
return new ResponsesChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_URL.replace(/\/$/, "")}/api/v1/responses`,
headers: async (): Promise<Record<string, string>> => {
@@ -1323,12 +1337,13 @@ function createWorkbenchAgentTransport(client: AgentApiClient, access?: Workbenc
},
async prepareSendMessagesRequest({ messages, body }) {
const catalog = await client.getModels();
onModels?.(catalog);
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? "");
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, {
conversation: readBodyString(body, "session_id"), interactionId: readBodyString(body, "interaction_id"), model, topicId: readBodyString(body, "topic_id"),
approvalMode: readBodyString(body, "approval_mode") ?? "request",
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@2" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
}) };
},
});
@@ -628,8 +628,7 @@ function addMissingDataDependentLayers(
layers: StyleSpecification["layers"],
beforeLayerCandidates: readonly string[],
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
layerVisibility: Record<string, boolean>,
workspace?: string
layerVisibility: Record<string, boolean>
) {
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
layers.forEach((layer) => {
@@ -1,3 +1,4 @@
import { toLocalDateKey } from "@/shared/utils/date-time";
import { useEffect, useState } from "react";
import { getWorkbenchProject, type WorkbenchAccess } from "../api/request-context";
import { env } from "@/shared/config/env";
@@ -249,7 +250,7 @@ export function useWorkbenchRuntimeData(
: backendRuntimeActive
? "backend" as const
: "mock" as const,
operationalDate: formatLocalDateKey(operationalDate),
operationalDate: toLocalDateKey(operationalDate),
operationalCursorTime: operationalDate.toISOString(),
scheduledConditionsLoading,
realtimePipeFlows,
@@ -258,10 +259,3 @@ export function useWorkbenchRuntimeData(
scenarios: !allowDemoScenarios && resolvedRuntimeData.scenarioDataSource === "mock" ? [] : resolvedRuntimeData.scenarios
};
}
function formatLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
+62 -90
View File
@@ -15,13 +15,11 @@ import { ReadingWorkspaceProvider } from "@/shared/ui/reading-workspace";
import {
AgentCommandPanel,
AgentPersona,
parseFrontendChart,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
type AgentUiResult,
type AgentWorkspaceContextItem,
type AnswerUiSelection
type AgentWorkspaceContextItem
} from "@/features/agent";
import {
MapErrorNotice,
@@ -35,7 +33,6 @@ import {
type MapSourceStatus
} from "@/features/map/core";
import { env } from "@/shared/config/env";
import { waitForAgentPresentation } from "./map/agent-presentation";
import { getWorkbenchProject, type WorkbenchAccess } from "./api/request-context";
import type { WorkbenchProjectSelection } from "./components/workbench-top-bar-menus";
import { AgentTaskTicker } from "./components/agent-task-ticker";
@@ -48,7 +45,6 @@ import { InvestigationPresentationPanel } from "./components/investigation-prese
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { TaskConversationReview } from "./components/task-conversation-review";
import type { InvestigationDiscussion } from "./api/investigation-report";
import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
@@ -104,7 +100,6 @@ import {
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import { setMapLayersVisibility } from "./map/layer-visibility";
import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
@@ -116,7 +111,6 @@ import {
import { waterNetworkToolbarItems } from "./map/toolbar-config";
import type {
DetailFeature,
ScheduledConditionItem,
ScheduledConditionRecord,
WorkbenchAlert,
WorkbenchUser
@@ -520,6 +514,16 @@ function MapWorkbenchContent({
onClearLayers: restorePresentationLayers,
getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
});
const previewPresentation = useInvestigationPresentation({
scope: "preview", enabled: false, task: selectedInvestigationTask, projectId: projectPresentation.projectId,
sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController,
onClearLayers: () => undefined, getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
});
const chatPresentation = useInvestigationPresentation({
scope: "chat", scopeId: agent.sessionId, enabled: false, task: null, projectId: projectPresentation.projectId,
sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController,
onClearLayers: () => undefined, getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
});
const geocoding = useWorkbenchGeocoding({
picking: geocodingPickActive,
onPickingChange: (picking) => {
@@ -739,7 +743,7 @@ function MapWorkbenchContent({
}
const presentationLayerIds = new Set([
...presentation.highlightLayers,
...(selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? [])
...(presentation.history.selectedId === null ? selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? [] : [])
].flatMap((id) => id === "scada" ? ["scada-pressure", "scada-flow"] : [id]));
const presentationRelatedLayers = layerControlItems.filter((item) => presentationLayerIds.has(item.id)).map((item) => ({ id: item.id, label: item.label, visible: item.visible }));
@@ -942,56 +946,7 @@ function MapWorkbenchContent({
}
async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) {
signal.throwIfAborted();
if (request.name === "locate_features") {
const action = toTrustedMapAction("locate_features", request.params);
if (!action || action.type !== "locate_features" || !action.layer) throw new Error("INVALID_MAP_TARGET");
const result = await mapController.locateFeatures(action.layer as Parameters<typeof mapController.locateFeatures>[0], action.featureIds, signal);
return result;
}
if (request.name === "zoom_to_map") {
const action = toTrustedMapAction("zoom_to_map", request.params);
if (!action || action.type !== "zoom_to_map") throw new Error("INVALID_MAP_CAMERA");
const map = mapRef.current;
if (!map || !mapReady) throw new Error("MAP_NOT_READY");
map.jumpTo({ center: action.center, zoom: action.zoom });
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
signal.throwIfAborted();
return { fitted: true };
}
if (request.name === "show_chart" || request.name === "view_history") {
const chart = parseFrontendChart(request.name === "view_history" ? request.params.chart : request.params);
if (!chart) throw new Error("INVALID_CHART_DATA");
const data = { x_data: chart.x_data, series: chart.series };
const envelope: AgentUiResult["envelope"] = request.name === "view_history"
? { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: { feature_id: request.params.element_id, metric: chart.title, start_time: request.params.start, end_time: request.params.end }, data }
: { kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data };
setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope }]);
await waitForAgentPresentation(request.actionId, signal);
return { displayed: true, series_count: chart.series.length };
}
if (request.name === "render_scada_analysis") {
const items = parseScadaAnalysisItems(request.params.items);
if (!items) throw new Error("INVALID_SCADA_ANALYSIS");
const result = await mapController.renderScadaAnalysis(items, signal);
const counts = result.level_counts;
showMapNotice({
tone: "success",
title: "Agent SCADA 分析已渲染",
message: `已渲染 ${result.rendered_ids.length} 个,高 ${counts.high}、中 ${counts.medium}、低 ${counts.low}${result.missing_ids.length ? `,缺失 ${result.missing_ids.length} 个` : ""}。`
});
return result;
}
if (request.name === "clear_scada_analysis") {
const result = mapController.clearScadaAnalysis();
showMapNotice({
tone: "info",
title: "Agent SCADA 分析已清除",
message: "地图已移除当前会话的 SCADA 分析结果。"
});
return result;
}
throw new Error("UNSUPPORTED_FRONTEND_ACTION");
return chatPresentation.executeCommand(request.command, signal, "chat");
}
async function handleStartNewAgentSession() {
@@ -1135,6 +1090,8 @@ function MapWorkbenchContent({
function handleClearMapOverlays() {
mapController.clearOverlays();
presentation.clearMapOverlays();
chatPresentation.clearMapOverlays();
previewPresentation.clearMapOverlays();
handleClearSelection();
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
measurement.clear();
@@ -1215,7 +1172,7 @@ function MapWorkbenchContent({
}
return items;
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
const collaborationReviewTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId
const collaborationTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId
? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId)
: undefined;
const promptSuggestions = useAgentPromptSuggestions({
@@ -1225,6 +1182,7 @@ function MapWorkbenchContent({
model: agent.selectedModel,
sessionId: agent.sessionId,
request: agent.generatePromptSuggestions,
recoveryVersion: agent.connectionRecoveryVersion,
alerts: workbenchAlerts,
alertDataSource,
conditions: scheduledConditions,
@@ -1263,7 +1221,9 @@ function MapWorkbenchContent({
promptSuggestions: promptSuggestions.suggestions,
promptSuggestionsLoading: promptSuggestions.loading,
promptSuggestionsUnavailable: promptSuggestions.unavailable,
onRetryPromptSuggestions: promptSuggestions.retry,
activeSessionId: agent.sessionId,
answerStateScope: agent.answerStateScope,
qaOnly: agent.sessionPurpose === "task_collaboration" || agent.sessionPurpose === "task_execution",
executionHistory: agent.sessionPurpose === "task_execution",
statusLabel: agent.statusLabel,
@@ -1289,14 +1249,6 @@ function MapWorkbenchContent({
currentProjectName: getWorkbenchProject(getAccessToken)?.name,
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
reviewPanel: collaborationReviewTask ? <TaskConversationReview task={collaborationReviewTask}
onAccessDecision={(requestId, version, decision, scopes) => operationalTasks.decideAccess(collaborationReviewTask.task_id, requestId, version, decision, scopes)}
onActionDecision={(actionId, version, decision, comment) => operationalTasks.decideAction(collaborationReviewTask.task_id, actionId, version, decision, comment)}
onActionExecute={(actionId, version) => operationalTasks.executeAction(collaborationReviewTask.task_id, actionId, version)}
onDraftUpdate={(actionId, version, payload) => operationalTasks.updateWorkOrderDraft(collaborationReviewTask.task_id, actionId, version, payload)}
onConfirmDispatch={(actionId, version) => operationalTasks.confirmWorkOrderDispatch(collaborationReviewTask.task_id, actionId, version)}
onActionReconcile={(actionId, version) => operationalTasks.reconcileAction(collaborationReviewTask.task_id, actionId, version)}
onRefresh={operationalTasks.refresh} /> : undefined,
workspaceContext: agentWorkspaceContext,
prompt: agent.promptDraft,
contentReference: agent.contentReference,
@@ -1304,16 +1256,20 @@ function MapWorkbenchContent({
agent.setContentReference(value);
if (value) readingWorkspace?.close();
},
promptReference: collaborationReviewTask ? {
title: collaborationReviewTask.title,
placeholder: "可询问调查进展、讨论报告或补充信息",
detail: [
agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration
? `第 ${agent.taskDiscussionReportGeneration} 轮` : null,
agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null,
agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null,
agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null
].filter(Boolean).join(" · ")
promptReference: collaborationTask ? {
title: collaborationTask.title,
placeholder: agent.sessionPurpose === "task_execution"
? "追加调试问题,发送后进入独立调试会话"
: "可询问调查进展、讨论报告或补充信息",
detail: agent.sessionPurpose === "task_execution"
? "发送后进入调试分支 · 原始记录保持不变"
: [
agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration
? `第 ${agent.taskDiscussionReportGeneration} 轮` : null,
agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null,
agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null,
agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null
].filter(Boolean).join(" · ")
} : undefined,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
@@ -1332,12 +1288,12 @@ function MapWorkbenchContent({
onSubmitPrompt: agent.submitPrompt,
onLocateAnswerFeatures: async (target) => {
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
return mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal);
return chatPresentation.executeCommand({ action: "locate_features", layer: target.layer, feature_ids: target.feature_ids }, new AbortController().signal, "chat");
},
onStopPrompt: agent.stopPrompt,
onReplyPermission: agent.replyPermission,
onReplyQuestion: agent.replyQuestion,
onRejectQuestion: agent.rejectQuestion
onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt,
onReplyPermission: agent.sessionPurpose === "task_execution" ? undefined : agent.replyPermission,
onReplyQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.replyQuestion,
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
};
return (
@@ -1397,7 +1353,7 @@ function MapWorkbenchContent({
onLogout={onLogout}
/>
{analysisResultsPanelOpen || peripheralSamplePanelOpen ? (
{analysisResultsPanelOpen ? (
<div
aria-hidden="true"
data-map-interaction-shield
@@ -1501,13 +1457,21 @@ function MapWorkbenchContent({
}
mapOverlay={
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-14">
{investigationFollow && selectedInvestigationTask && !devPanelOpen ? <InvestigationPresentationPanel
task={selectedInvestigationTask} charts={presentation.charts} overlay={presentation.overlay} cameraPaused={presentation.cameraPaused}
status={presentation.status} error={presentation.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={presentationRelatedLayers} onToggleLayer={handleTogglePresentationLayer}
onResumeCamera={presentation.resumeCamera} onCloseChart={presentation.closeChart} onToggleOverlay={presentation.toggleOverlay} onFitOverlay={presentation.fitOverlay}
/> : null}
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 flex max-h-[calc(100dvh-9rem-var(--workbench-timeline-height))] max-w-[calc(100%-24px)] flex-col gap-2 overflow-y-auto">
<AnimatePresence initial={false}>
{[
{ scope: "investigation", surface: presentation, task: selectedInvestigationTask, visible: Boolean(selectedInvestigationTask) || presentation.hasHistory },
{ scope: "chat", surface: chatPresentation, task: null, visible: chatPresentation.hasHistory || chatPresentation.charts.length > 0 || Boolean(chatPresentation.overlay) || chatPresentation.highlightLayers.length > 0 },
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel
key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused}
status={scope === "chat" ? "live" : surface.status} error={surface.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
onResumeCamera={surface.resumeCamera} onCloseChart={surface.closeChart} onToggleOverlay={surface.toggleOverlay} onFitOverlay={surface.fitOverlay}
/>)}
</AnimatePresence>
</div>
{!devPanelOpen ? (
<>
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
@@ -1577,6 +1541,7 @@ function MapWorkbenchContent({
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onLoadTaskReports={operationalTasks.loadReports}
onAnswerTaskQuestion={operationalTasks.answerQuestion}
onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh}
/>
@@ -1591,6 +1556,12 @@ function MapWorkbenchContent({
flowDataSource={flowDataSource}
investigationFollow={investigationFollow}
onInvestigationFollowChange={setInvestigationFollow}
investigationTools={{
task: selectedInvestigationTask, mapReady,
charts: previewPresentation.charts.length, overlay: previewPresentation.overlay?.title ?? null,
activity: [...presentation.activity, ...previewPresentation.activity], onSample: previewPresentation.createSample,
onPreview: previewPresentation.runPreview, onClear: previewPresentation.clearPreview
}}
onClose={() => setDevPanelOpen(false)}
/>
) : null}
@@ -1735,6 +1706,7 @@ function MapWorkbenchContent({
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onLoadTaskReports={operationalTasks.loadReports}
onAnswerTaskQuestion={operationalTasks.answerQuestion}
onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh}
/>
@@ -1,10 +0,0 @@
export const waitForAgentPresentation = async (id: string, signal: AbortSignal) => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
signal.throwIfAborted();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const element = [...document.querySelectorAll<HTMLElement>("[data-agent-ui-result]")].find((item) => item.dataset.agentUiResult === id);
if (element && element.getBoundingClientRect().height > 0) return;
}
throw new Error("PRESENTATION_NOT_VISIBLE");
};
+1 -8
View File
@@ -1,3 +1,4 @@
import { webMercatorToLngLat } from "@/features/map/core";
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { getWorkbenchCameraPadding } from "../layout/workbench-layout";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
@@ -10,7 +11,6 @@ export type NetworkViewParams = {
bbox3857: WebMercatorBbox;
};
const WEB_MERCATOR_RADIUS = 6378137;
const GLOBAL_VIEW_MAX_ZOOM = 13;
const GLOBAL_VIEW_PADDING: CompletePaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
export function getWorkbenchPadding(
@@ -108,10 +108,3 @@ function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
return [webMercatorToLngLat(minX, minY), webMercatorToLngLat(maxX, maxY)];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { Map as MapLibreMap } from "maplibre-gl";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import {
InvestigationMapPresentation,
INVESTIGATION_OVERLAY_SOURCE
} from "./investigation-map-presentation";
const data: InvestigationOverlay["data"] = {
type: "FeatureCollection",
features: [
{ type: "Feature", properties: { device_type: "pressure", [PRESENTATION_FEATURE_SOURCE]: "scada" }, geometry: { type: "Point", coordinates: [121.48, 31.1] } }
]
};
function setup(reduced = false) {
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: reduced }))
);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 16)
);
vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));
const visibility = new Map<string, unknown>();
const paint = new Map<string, unknown>();
const source = { setData: vi.fn() };
const map = {
getSource: vi.fn(() => source),
getLayer: vi.fn(() => ({})),
getLayoutProperty: vi.fn(
(id: string, property: string) => visibility.get(`${id}:${property}`) ?? "none"
),
setLayoutProperty: vi.fn((id: string, property: string, value: unknown) =>
visibility.set(`${id}:${property}`, value)
),
setPaintProperty: vi.fn((id: string, property: string, value: unknown) =>
paint.set(`${id}:${property}`, value)
),
fitBounds: vi.fn(),
stop: vi.fn(),
removeLayer: vi.fn(),
removeSource: vi.fn()
};
const scene = new InvestigationMapPresentation(
() => map as unknown as MapLibreMap,
() => ({ top: 24, right: 400, bottom: 128, left: 24 })
);
const overlay = {
action: "set_overlay",
overlay_id: "test-area",
title: "合成区域",
mode: "geometry",
evidence_id: "test",
visible: true,
data
} as const;
return { scene, map, paint, visibility, overlay, source };
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("investigation map motion", () => {
it("pulses selection opacity without changing native circle sizes, then restores its static style", () => {
vi.useFakeTimers();
const { scene, map, paint } = setup();
scene.highlight(data, true, false);
vi.advanceTimersByTime(32);
const id = "investigation-presentation-highlights-scada-pressure-selected";
expect(paint.get(`${id}:circle-stroke-opacity`)).toBeLessThan(1);
expect(map.setPaintProperty.mock.calls.some(([, property]) => property === "circle-radius")).toBe(false);
scene.stopPulse();
expect(paint.get(`${id}:circle-stroke-opacity`)).toBe(1);
const calls = map.setPaintProperty.mock.calls.length;
vi.advanceTimersByTime(100);
expect(map.setPaintProperty).toHaveBeenCalledTimes(calls);
});
it.each([false, true])(
"fits smoothly, respects reduced motion %s and stops the previous camera movement",
(reduced) => {
const { scene, map } = setup(reduced);
scene.highlight(data, false, true);
scene.fitHighlights();
expect(map.stop).toHaveBeenCalledTimes(2);
expect(map.fitBounds).toHaveBeenLastCalledWith(
[
[121.48, 31.1],
[121.48, 31.1]
],
expect.objectContaining({
duration: reduced ? 0 : 450,
linear: true,
maxZoom: 18,
padding: { top: 24, right: 400, bottom: 128, left: 24 }
})
);
expect(map.stop.mock.invocationCallOrder[1]).toBeLessThan(
map.fitBounds.mock.invocationCallOrder[1]
);
}
);
it("fades overlays and cancels an outdated hide when the user shows them again", () => {
vi.useFakeTimers();
const { scene, paint, visibility, overlay } = setup();
const id = `${INVESTIGATION_OVERLAY_SOURCE}-point`;
scene.setOverlay(overlay, false);
expect(paint.get(`${id}:circle-opacity`)).toBe(0);
vi.advanceTimersByTime(20);
expect(paint.get(`${id}:circle-opacity`)).toBe(1);
expect(paint.get(`${id}:circle-opacity-transition`)).toEqual({ duration: 180, delay: 0 });
scene.showOverlay(false);
expect(paint.get(`${id}:circle-opacity`)).toBe(0);
expect(visibility.get(`${id}:visibility`)).toBe("visible");
vi.advanceTimersByTime(100);
scene.showOverlay(true);
vi.advanceTimersByTime(200);
expect(visibility.get(`${id}:visibility`)).toBe("visible");
expect(paint.get(`${id}:circle-opacity`)).toBe(1);
scene.showOverlay(false);
vi.advanceTimersByTime(181);
expect(visibility.get(`${id}:visibility`)).toBe("none");
});
it("hides immediately with reduced motion and cancels callbacks during clear or destroy", () => {
vi.useFakeTimers();
const reduced = setup(true);
reduced.scene.setOverlay(reduced.overlay, false);
reduced.scene.showOverlay(false);
expect(reduced.visibility.get(`${INVESTIGATION_OVERLAY_SOURCE}-point:visibility`)).toBe("none");
const { scene, map, overlay } = setup();
scene.setOverlay(overlay, false);
scene.clearOverlay();
const calls = map.setPaintProperty.mock.calls.length;
vi.advanceTimersByTime(500);
expect(map.setPaintProperty).toHaveBeenCalledTimes(calls);
scene.setOverlay(overlay, false);
scene.destroy();
const afterDestroy = map.setPaintProperty.mock.calls.length;
vi.advanceTimersByTime(500);
expect(map.setPaintProperty).toHaveBeenCalledTimes(afterDestroy);
});
});
describe("presentation ownership", () => {
it("renders and clears only the owning surface's SCADA data and map sources", () => {
const { map, source } = setup(true);
const scene = new InvestigationMapPresentation(() => map as unknown as MapLibreMap, () => ({ top: 24, right: 24, bottom: 24, left: 24 }), "chat");
const result = scene.renderScadaAnalysis({ type: "FeatureCollection", features: [
{ type: "Feature", properties: { device_id: "D1" }, geometry: { type: "Point", coordinates: [121, 31] } }
] }, [{ device_id: "D1", level: "high" }, { device_id: "missing", level: "low" }]);
expect(result).toEqual({ rendered_ids: ["D1"], missing_ids: ["missing"], level_counts: { high: 1, medium: 0, low: 0, unrated: 0 }, fitted: true });
expect(source.setData).toHaveBeenCalledWith(expect.objectContaining({ features: [expect.objectContaining({ properties: expect.objectContaining({ analysis_level: "high" }) })] }));
scene.clear(); scene.destroy();
expect(map.removeSource.mock.calls.map(([id]) => id)).toEqual(["chat-presentation-highlights", "chat-presentation-overlay", "chat-presentation-scada"]);
expect(map.removeLayer.mock.calls.every(([id]) => id.startsWith("chat-presentation-"))).toBe(true);
expect(source.setData).toHaveBeenLastCalledWith({ type: "FeatureCollection", features: [] });
});
});
@@ -1,17 +1,17 @@
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions, LayerSpecification } from "maplibre-gl";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import { createScadaAnalysisCollection, createEmptyScadaAnalysisCollection, scadaAnalysisLayers, SCADA_ANALYSIS_SOURCE_ID, type ScadaAnalysisItem } from "./scada-analysis";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { createPresentationHighlightLayers, presentationFeatureSource, PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
export const INVESTIGATION_HIGHLIGHT_SOURCE = "investigation-presentation-highlights";
export const INVESTIGATION_OVERLAY_SOURCE = "investigation-presentation-overlay";
const empty = (): FeatureCollection => ({ type: "FeatureCollection", features: [] });
const color = MAP_STYLE_TOKENS.state.selected;
type OverlayOpacity = "fill-opacity" | "circle-opacity" | "circle-stroke-opacity" | "heatmap-opacity" | "line-opacity";
type OverlayOpacityValues = Partial<Record<OverlayOpacity, number>>;
const layers: LayerSpecification[] = [
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-fill`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": color, "fill-opacity": 0.18 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "line", filter: ["!=", ["geometry-type"], "Point"], paint: { "line-color": color, "line-width": 5, "line-opacity": 0.85 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": color, "circle-radius": 11, "circle-opacity": 0.65, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.supply.waterQuality, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-line`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 3 } },
@@ -38,16 +38,33 @@ export class InvestigationMapPresentation {
private pulseDeadline: number | null = null;
private highlighted = empty();
private overlay: InvestigationOverlay | null = null;
private overlayFrame: number | null = null;
private overlayHideTimer: number | null = null;
constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions) {}
private readonly highlightSource: string;
private readonly overlaySource: string;
private readonly scadaSource: string;
private readonly scadaLayers: LayerSpecification[];
private readonly layers: LayerSpecification[];
constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions, scope = "investigation") {
this.highlightSource = `${scope}-presentation-highlights`;
this.overlaySource = `${scope}-presentation-overlay`;
this.scadaSource = `${scope}-presentation-scada`;
this.scadaLayers = scadaAnalysisLayers.map((layer) => ({ ...layer, id: layer.id.replace(SCADA_ANALYSIS_SOURCE_ID, this.scadaSource), source: this.scadaSource } as LayerSpecification));
this.layers = layers.map((layer) => ({ ...layer,
id: layer.id.replace(INVESTIGATION_HIGHLIGHT_SOURCE, this.highlightSource).replace(INVESTIGATION_OVERLAY_SOURCE, this.overlaySource),
source: String("source" in layer ? layer.source : "").replace(INVESTIGATION_HIGHLIGHT_SOURCE, this.highlightSource).replace(INVESTIGATION_OVERLAY_SOURCE, this.overlaySource)
} as LayerSpecification));
}
private ensure() {
const map = this.getMap();
if (!map) throw new Error("MAP_NOT_READY");
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) {
for (const id of [this.highlightSource, this.overlaySource]) {
if (!map.getSource(id)) map.addSource(id, { type: "geojson", data: empty() });
}
for (const layer of layers) {
for (const layer of this.layers) {
if (!map.getLayer(layer.id)) map.addLayer({ ...layer, layout: { visibility: "none" } });
}
return map;
@@ -55,17 +72,31 @@ export class InvestigationMapPresentation {
highlight(data: FeatureCollection, pulse: boolean, fit: boolean) {
this.stopPulse();
const nativeLayers = this.getMap()?.getStyle?.()?.layers;
for (const value of new Set(data.features.map((feature) => feature.properties?.[PRESENTATION_FEATURE_SOURCE]))) {
const source = presentationFeatureSource(value);
if (!source) continue;
for (const layer of createPresentationHighlightLayers(source, this.highlightSource, nativeLayers)) {
const index = this.layers.findIndex((current) => current.id === layer.id);
if (index < 0) this.layers.push(layer);
else {
this.layers[index] = layer;
const map = this.getMap();
if (map?.getLayer(layer.id) && "paint" in layer)
for (const [property, value] of Object.entries(layer.paint ?? {}))
map.setPaintProperty(layer.id, property as Parameters<MapLibreMap["setPaintProperty"]>[1], value);
}
}
}
const map = this.ensure();
this.highlighted = data;
(map.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource).setData(data);
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, data.features.length > 0);
(map.getSource(this.highlightSource) as GeoJSONSource).setData(data);
this.visibility(this.highlightSource, data.features.length > 0);
if (fit) this.fit(data);
if (pulse && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const animate = (time: number) => {
if (!map.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) return;
const wave = (Math.sin(time / 300) + 1) / 2;
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 9 + wave * 5);
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.45 + wave * 0.4);
this.highlightOpacity(0.45 + wave * 0.55);
this.pulseFrame = requestAnimationFrame(animate);
};
this.pulseFrame = requestAnimationFrame(animate);
@@ -78,40 +109,94 @@ export class InvestigationMapPresentation {
this.pulseFrame = null;
if (this.pulseDeadline !== null) window.clearTimeout(this.pulseDeadline);
this.pulseDeadline = null;
this.highlightOpacity(1);
}
private highlightOpacity(opacity: number) {
const map = this.getMap();
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 11);
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.85);
for (const layer of this.layers) {
if (!("source" in layer) || layer.source !== this.highlightSource || !map?.getLayer(layer.id)) continue;
if (layer.type === "line") map.setPaintProperty(layer.id, "line-opacity", opacity);
else if (layer.type === "circle") map.setPaintProperty(layer.id, "circle-stroke-opacity", opacity);
}
}
clearHighlights() {
this.stopPulse();
this.highlighted = empty();
(this.getMap()?.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, false);
(this.getMap()?.getSource(this.highlightSource) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(this.highlightSource, false);
}
setOverlay(overlay: InvestigationOverlay, fit: boolean) {
const map = this.ensure();
this.overlay = overlay;
(map.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource).setData(overlay.data);
(map.getSource(this.overlaySource) as GeoJSONSource).setData(overlay.data);
this.showOverlay(overlay.visible);
if (fit) this.fit(overlay.data);
}
private cancelOverlayTransition() {
if (this.overlayFrame !== null) cancelAnimationFrame(this.overlayFrame);
if (this.overlayHideTimer !== null) window.clearTimeout(this.overlayHideTimer);
this.overlayFrame = null;
this.overlayHideTimer = null;
}
showOverlay(visible: boolean) {
this.cancelOverlayTransition();
if (!this.overlay) return;
this.overlay = { ...this.overlay, visible };
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === INVESTIGATION_OVERLAY_SOURCE)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap") ? "visible" : "none");
if (!map) return;
const duration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 180;
const entering: Array<{ id: string; opacity: OverlayOpacityValues }> = [];
const hiding: string[] = [];
const paint = (id: string, opacity: OverlayOpacityValues, shown: boolean, milliseconds: number) => {
if (!map.getLayer(id)) return;
for (const [property, value] of Object.entries(opacity) as Array<[OverlayOpacity, number]>) {
map.setPaintProperty(id, `${property}-transition` as const, { duration: milliseconds, delay: 0 });
map.setPaintProperty(id, property, shown ? value : 0);
}
};
for (const layer of this.layers.filter((item) => "source" in item && item.source === this.overlaySource)) {
if (!map.getLayer(layer.id)) continue;
const opacity: OverlayOpacityValues = layer.type === "fill" ? { "fill-opacity": 0.16 }
: layer.type === "circle" ? { "circle-opacity": 1, "circle-stroke-opacity": 1 }
: layer.type === "heatmap" ? { "heatmap-opacity": 0.65 } : { "line-opacity": 1 };
const shown = visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap");
const wasHidden = map.getLayoutProperty(layer.id, "visibility") === "none";
if (shown) {
if (duration && wasHidden) {
paint(layer.id, opacity, false, 0);
map.setLayoutProperty(layer.id, "visibility", "visible");
entering.push({ id: layer.id, opacity });
} else {
map.setLayoutProperty(layer.id, "visibility", "visible");
paint(layer.id, opacity, true, duration);
}
} else {
paint(layer.id, opacity, false, duration);
if (!duration || wasHidden) map.setLayoutProperty(layer.id, "visibility", "none");
else hiding.push(layer.id);
}
}
if (entering.length) this.overlayFrame = requestAnimationFrame(() => {
this.overlayFrame = null;
for (const layer of entering) paint(layer.id, layer.opacity, true, duration);
});
if (hiding.length) this.overlayHideTimer = window.setTimeout(() => {
this.overlayHideTimer = null;
for (const id of hiding) if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", "none");
}, duration);
}
clearOverlay(id?: string) {
if (id && id !== this.overlay?.overlay_id) return;
this.cancelOverlayTransition();
this.overlay = null;
(this.getMap()?.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_OVERLAY_SOURCE, false);
(this.getMap()?.getSource(this.overlaySource) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(this.overlaySource, false);
}
fitOverlay() { if (this.overlay) this.fit(this.overlay.data); }
@@ -119,7 +204,7 @@ export class InvestigationMapPresentation {
private visibility(source: string, visible: boolean) {
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === source)) {
for (const layer of this.layers.filter((item) => "source" in item && item.source === source)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible ? "visible" : "none");
}
}
@@ -128,15 +213,40 @@ export class InvestigationMapPresentation {
const positions = data.features.flatMap((feature) => feature.geometry ? investigationGeometryCoordinates(feature.geometry) : []);
if (!positions.length) return;
const bounds = positions.reduce((value, point) => [Math.min(value[0], point[0]), Math.min(value[1], point[1]), Math.max(value[2], point[0]), Math.max(value[3], point[1])], [Infinity, Infinity, -Infinity, -Infinity]);
this.getMap()?.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.getPadding(), maxZoom: 18, duration: 0 });
const map = this.getMap();
if (!map) return;
map.stop();
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
padding: this.getPadding(), maxZoom: 18, linear: true,
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 450,
easing: (progress: number) => 1 - (1 - progress) ** 3
});
}
clear() { this.clearHighlights(); this.clearOverlay(); }
renderScadaAnalysis(data: FeatureCollection, items: ScadaAnalysisItem[], fit = true) {
const map = this.getMap();
if (!map) throw new Error("MAP_NOT_READY");
const next = createScadaAnalysisCollection(data, items);
if (!next.result.rendered_ids.length) throw new Error("SCADA_FEATURES_NOT_FOUND");
if (!map.getSource(this.scadaSource)) map.addSource(this.scadaSource, { type: "geojson", data: createEmptyScadaAnalysisCollection() });
for (const layer of this.scadaLayers) if (!map.getLayer(layer.id)) map.addLayer(layer);
(map.getSource(this.scadaSource) as GeoJSONSource).setData(next.collection);
if (fit) this.fit(next.collection);
return { ...next.result, fitted: fit };
}
clearScadaAnalysis() {
(this.getMap()?.getSource(this.scadaSource) as GeoJSONSource | undefined)?.setData(createEmptyScadaAnalysisCollection());
return { cleared: true as const };
}
clear() { this.clearHighlights(); this.clearOverlay(); this.clearScadaAnalysis(); }
destroy() {
this.cancelOverlayTransition();
this.stopPulse();
const map = this.getMap();
if (!map) return;
for (const layer of [...layers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id);
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) if (map.getSource(id)) map.removeSource(id);
for (const layer of [...this.layers, ...this.scadaLayers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id);
for (const id of [this.highlightSource, this.overlaySource, this.scadaSource]) if (map.getSource(id)) map.removeSource(id);
}
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import type { FeatureCollection } from "geojson";
import { presentationCommandSchema } from "@/features/agent";
import {
executePresentationCommand,
type PresentationPorts
} from "./presentation-command-executor";
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { investigationPresentationEventSchema } from "../api/investigation-presentation";
const chart = {
title: "压力",
chart_type: "line",
x_data: ["10:00"],
series: [{ name: "D1", data: [0.4] }]
};
const data: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { id: "D1" },
geometry: { type: "Point", coordinates: [121, 31] }
}
]
};
const commands = [
{ action: "locate_features", layer: "scada", feature_ids: ["D1", "missing"] },
{ action: "zoom_to_map", center: [121, 31], zoom: 14 },
{ action: "highlight_features", layer: "scada", feature_ids: ["D1", "missing"], effect: "pulse" },
{ action: "clear_highlights" },
{ action: "open_chart", chart_id: "pressure", chart, evidence_refs: ["E1"] },
{ action: "close_chart", chart_id: "pressure" },
{
action: "set_overlay",
overlay_id: "points",
title: "证据",
mode: "geometry",
evidence_id: "E1",
data
},
{ action: "clear_overlay", overlay_id: "points" },
{ action: "render_scada_analysis", items: [{ device_id: "D1", level: "high" }] },
{ action: "clear_scada_analysis" }
];
function ports(): PresentationPorts {
return {
controller: { queryPresentationFeatures: vi.fn(async () => data) },
scene: {
highlight: vi.fn(),
clearHighlights: vi.fn(),
setOverlay: vi.fn(),
clearOverlay: vi.fn(),
renderScadaAnalysis: vi.fn(() => ({
rendered_ids: ["D1"],
missing_ids: [],
fitted: true as const,
level_counts: { high: 1, medium: 0, low: 0, unrated: 0 }
})),
clearScadaAnalysis: vi.fn(() => ({ cleared: true as const }))
},
cameraAllowed: () => false,
zoom: vi.fn(async () => {}),
openChart: vi.fn(async () => {}),
closeChart: vi.fn(async () => {}),
overlayChanged: vi.fn(),
highlightsChanged: vi.fn()
};
}
describe("shared presentation execution", () => {
it.each(commands)(
"accepts the same $action payload through chat, Dev and task events",
async (input) => {
const chat = presentationCommandSchema.parse(input);
const preview = parseInvestigationDevJsonl(
JSON.stringify({ record_type: "presentation", command: input })
).commands[0];
const task = investigationPresentationEventSchema.parse({
task_id: "71000000-0000-4000-8000-000000000001",
run_id: "71000000-0000-4000-8000-000000000002",
execution_generation: 1,
command: input
}).command;
const results = await Promise.all(
[chat, preview, task].map((command) =>
executePresentationCommand(command, new AbortController().signal, ports())
)
);
expect(results[1]).toEqual(results[0]);
expect(results[2]).toEqual(results[0]);
}
);
it("reports actual matching IDs and respects paused automatic camera following", async () => {
const target = ports();
expect(
await executePresentationCommand(
presentationCommandSchema.parse(commands[2]),
new AbortController().signal,
target
)
).toEqual({ highlighted_ids: ["D1"], missing_ids: ["missing"], fitted: false });
expect(target.scene.highlight).toHaveBeenCalledWith(data, true, false);
});
it("rejects empty targets and cancelled actions before any scene mutation", async () => {
const target = ports();
vi.mocked(target.controller.queryPresentationFeatures).mockResolvedValueOnce({
type: "FeatureCollection",
features: []
});
await expect(
executePresentationCommand(
presentationCommandSchema.parse(commands[0]),
new AbortController().signal,
target
)
).rejects.toThrow("FEATURE_NOT_FOUND");
const controller = new AbortController();
controller.abort();
await expect(
executePresentationCommand(
presentationCommandSchema.parse(commands[3]),
controller.signal,
target
)
).rejects.toThrow();
expect(target.scene.highlight).not.toHaveBeenCalled();
expect(target.scene.clearHighlights).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import type { FeatureCollection } from "geojson";
import type { PresentationCommand } from "@/features/agent";
import type { WorkbenchMapController } from "./workbench-map-controller";
import type { InvestigationMapPresentation } from "./investigation-map-presentation";
type Chart = Extract<PresentationCommand, { action: "open_chart" }>;
type Overlay = Extract<PresentationCommand, { action: "set_overlay" }>;
export type PresentationPorts = {
controller: Pick<WorkbenchMapController, "queryPresentationFeatures">;
scene: Pick<
InvestigationMapPresentation,
| "highlight"
| "clearHighlights"
| "setOverlay"
| "clearOverlay"
| "renderScadaAnalysis"
| "clearScadaAnalysis"
>;
cameraAllowed: () => boolean;
zoom: (center: [number, number], zoom: number, signal: AbortSignal) => Promise<void>;
openChart: (chart: Chart, signal: AbortSignal) => Promise<void>;
closeChart: (id: string, signal: AbortSignal) => Promise<void>;
overlayChanged: (overlay: Overlay | null, id?: string) => void;
highlightsChanged: (layers: string[]) => void;
};
/** One dispatcher for chat, task following and explicit Dev previews. Ownership lives in its ports. */
export async function executePresentationCommand(
command: PresentationCommand,
signal: AbortSignal,
ports: PresentationPorts
): Promise<Record<string, unknown>> {
signal.throwIfAborted();
switch (command.action) {
case "locate_features":
case "highlight_features": {
const data: FeatureCollection = await ports.controller.queryPresentationFeatures(
command.layer,
command.feature_ids,
signal
);
signal.throwIfAborted();
const found = new Set(
data.features.map((feature) =>
String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id)
)
);
const matched = [...new Set(command.feature_ids)].filter((id) => found.has(id));
if (!matched.length) throw new Error("FEATURE_NOT_FOUND");
const fitted = command.action === "locate_features" || ports.cameraAllowed();
ports.scene.highlight(
data,
command.action === "highlight_features" && command.effect === "pulse",
fitted
);
ports.highlightsChanged([command.layer]);
const missing_ids = [...new Set(command.feature_ids)].filter((id) => !found.has(id));
return command.action === "locate_features"
? { located_ids: matched, missing_ids, fitted: true }
: { highlighted_ids: matched, missing_ids, fitted };
}
case "zoom_to_map":
await ports.zoom(command.center, command.zoom, signal);
return { fitted: true };
case "clear_highlights":
ports.scene.clearHighlights();
ports.highlightsChanged([]);
return { cleared: true };
case "open_chart":
await ports.openChart(command, signal);
signal.throwIfAborted();
return { displayed: true, series_count: command.chart.series.length };
case "close_chart":
await ports.closeChart(command.chart_id, signal);
signal.throwIfAborted();
return { closed: true };
case "set_overlay":
ports.scene.setOverlay({ ...command, visible: true }, ports.cameraAllowed());
ports.overlayChanged(command);
return { displayed: true, feature_count: command.data.features.length };
case "clear_overlay":
ports.scene.clearOverlay(command.overlay_id);
ports.overlayChanged(null, command.overlay_id);
return { cleared: true };
case "render_scada_analysis": {
const data = await ports.controller.queryPresentationFeatures(
"scada",
command.items.map((item) => item.device_id),
signal
);
signal.throwIfAborted();
return ports.scene.renderScadaAnalysis(data, command.items);
}
case "clear_scada_analysis":
return ports.scene.clearScadaAnalysis();
}
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { convertFilter, featureFilter } from "@maplibre/maplibre-gl-style-spec";
import type { LayerSpecification } from "maplibre-gl";
import { createPresentationHighlightLayers, PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import { waterNetworkLayers } from "./layers";
import { scadaInteractionLayers } from "./scada";
import { WATER_NETWORK_SOURCE_IDS } from "./sources";
const native = [...waterNetworkLayers, ...scadaInteractionLayers];
describe("presentation selection style", () => {
it.each(WATER_NETWORK_SOURCE_IDS)("uses map-debug selection paint and zoom rules for %s", (source) => {
const selected = native.filter((layer) => "source" in layer && layer.source === source && /-selected(?:-|$)/.test(layer.id));
expect(selected.length).toBeGreaterThan(0);
const original = structuredClone(selected);
const actual = createPresentationHighlightLayers(source, "chat-owned", native);
expect(actual).toHaveLength(selected.length);
actual.forEach((layer, index) => {
const reference = selected[index];
if ((layer.type !== "line" && layer.type !== "circle") || (reference.type !== "line" && reference.type !== "circle")) throw new Error("missing selection fixture");
expect(layer).toMatchObject({ id: `chat-owned-${reference.id}`, source: "chat-owned", minzoom: reference.minzoom });
expect(layer).not.toHaveProperty("source-layer");
expect(layer.filter).toEqual(reference.filter !== undefined
? ["all", ["==", ["get", PRESENTATION_FEATURE_SOURCE], source], convertFilter(reference.filter)]
: ["==", ["get", PRESENTATION_FEATURE_SOURCE], source]);
if ((layer.type === "line" || layer.type === "circle") && (reference.type === "line" || reference.type === "circle")) {
for (const [key, value] of Object.entries(reference.paint ?? {})) {
expect((layer.paint as Record<string, unknown>)[key]).toEqual(key.endsWith("opacity") ? 1 : value);
}
}
});
expect(selected).toEqual(original);
});
it("preserves legacy geometry filters when combining source ownership", () => {
const native: LayerSpecification[] = [{ id: "pipes-selected", source: "pipes", type: "line", filter: ["==", "$type", "LineString"], paint: { "line-width": 5 } }];
const layer = createPresentationHighlightLayers("pipes", "chat-owned", native)[0];
if (layer.type !== "line") throw new Error("missing line fixture");
const filter = featureFilter(layer.filter, "test").filter;
expect(filter({ zoom: 14 }, { type: 2, properties: { [PRESENTATION_FEATURE_SOURCE]: "pipes" } })).toBe(true);
expect(filter({ zoom: 14 }, { type: 1, properties: { [PRESENTATION_FEATURE_SOURCE]: "pipes" } })).toBe(false);
expect(filter({ zoom: 14 }, { type: 2, properties: { [PRESENTATION_FEATURE_SOURCE]: "junctions" } })).toBe(false);
});
it("uses current map overrides and keeps source scopes separate", () => {
const selected = scadaInteractionLayers.find((layer) => layer.type === "circle" && layer.id === "scada-pressure-selected")!;
if (selected.type !== "circle") throw new Error("missing circle fixture");
const current = { ...selected, paint: { ...selected.paint, "circle-radius": 20 } };
const chat = createPresentationHighlightLayers("scada", "chat-owned", [current]);
const preview = createPresentationHighlightLayers("scada", "preview-owned", [current]);
expect(chat[0]).toMatchObject({ source: "chat-owned", paint: { "circle-radius": 20 } });
expect(preview[0]).toMatchObject({ source: "preview-owned", paint: { "circle-radius": 20 } });
expect(chat[0].id).not.toBe(preview[0].id);
expect(selected.paint?.["circle-radius"]).not.toBe(20);
});
});
@@ -0,0 +1,38 @@
import type { FilterSpecification, LayerSpecification } from "maplibre-gl";
import { convertFilter } from "@maplibre/maplibre-gl-style-spec";
import { waterNetworkLayers } from "./layers";
import { scadaInteractionLayers } from "./scada";
import { WATER_NETWORK_SOURCE_IDS, type WaterNetworkSourceId } from "./sources";
export const PRESENTATION_FEATURE_SOURCE = "__tjwater_presentation_source";
const sourceIds = new Set<string>(WATER_NETWORK_SOURCE_IDS);
const defaults = [...waterNetworkLayers, ...scadaInteractionLayers];
export function presentationFeatureSource(value: unknown): WaterNetworkSourceId | null {
return typeof value === "string" && sourceIds.has(value) ? value as WaterNetworkSourceId : null;
}
/** Reuse map-debug selection paint, filters and zoom rules in an owned GeoJSON source. */
export function createPresentationHighlightLayers(
sourceId: WaterNetworkSourceId,
overlaySource: string,
nativeLayers: readonly LayerSpecification[] = defaults
): LayerSpecification[] {
return nativeLayers.filter((layer) => "source" in layer && layer.source === sourceId &&
/-selected(?:-|$)/.test(layer.id) && (layer.type === "line" || layer.type === "circle")
).map((original) => {
const layer = structuredClone(original);
if (layer.type !== "line" && layer.type !== "circle") throw new Error("INVALID_SELECTION_LAYER");
const identity: FilterSpecification = ["==", ["get", PRESENTATION_FEATURE_SOURCE], sourceId];
// GeoJSON has no vector source-layer, and ownership replaces feature-state
// gating. Keep the native device/geometry filters and visual expressions.
delete layer["source-layer"];
layer.source = overlaySource;
layer.id = `${overlaySource}-${original.id}`;
layer.filter = layer.filter !== undefined ? ["all", identity, convertFilter(layer.filter)] : identity;
layer.layout = { ...layer.layout, visibility: "none" };
if (layer.type === "line") layer.paint = { ...layer.paint, "line-opacity": 1 };
else layer.paint = { ...layer.paint, "circle-opacity": 1, "circle-stroke-opacity": 1 };
return layer;
});
}
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { InvestigationMapPresentation } from "./investigation-map-presentation";
import { executePresentationCommand } from "./presentation-command-executor";
import { presentationHistoryPorts, renderPresentationSnapshot } from "./presentation-history-ports";
import { PresentationViewHistory } from "../utils/presentation-view-history";
import type { FeatureCollection } from "geojson";
const data: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { device_id: "P1" },
geometry: { type: "Point", coordinates: [121.48, 31.1] }
}
]
};
afterEach(() => vi.unstubAllGlobals());
describe("presentation snapshots", () => {
it("captures returned geometry and camera, receives new actions offscreen and restores history without querying again", async () => {
vi.stubGlobal("matchMedia", () => ({ matches: false }));
const history = new PresentationViewHistory();
const map = {
getCenter: () => ({ toArray: () => [120, 30] }),
getZoom: () => 14,
getPitch: () => 0,
getBearing: () => 0,
cameraForBounds: vi.fn(() => ({ center: [121.48, 31.1], zoom: 18, bearing: 0 })),
stop: vi.fn(),
easeTo: vi.fn()
};
const scene = {
clear: vi.fn(),
highlight: vi.fn(),
setOverlay: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearHighlights: vi.fn(),
clearOverlay: vi.fn(),
clearScadaAnalysis: vi.fn()
};
const controller = { queryPresentationFeatures: vi.fn(async () => data) };
const ports = presentationHistoryPorts({
controller,
scene: scene as unknown as InvestigationMapPresentation,
map: () => map as unknown as MapLibreMap,
padding: () => ({ top: 24, right: 24, bottom: 24, left: 24 }),
update: (updater) => history.update(updater(history.current)),
rendering: () => history.selectedId === null,
cameraAllowed: () => true,
frame: async () => undefined,
waitForChart: vi.fn(async () => undefined)
});
const command = { action: "locate_features", layer: "scada", feature_ids: ["P1"] } as const;
await executePresentationCommand(
{ ...command, feature_ids: [...command.feature_ids] },
new AbortController().signal,
ports
);
const id = history.record("定位", "live", null);
history.select(id);
// Changing the source data later cannot rewrite the captured geometry.
data.features[0].properties = { device_id: "P2" };
await executePresentationCommand(
{
action: "open_chart",
chart_id: "later",
evidence_refs: [],
chart: {
title: "最新",
chart_type: "line",
x_data: ["08:00"],
series: [{ name: "压力", data: [32] }]
}
},
new AbortController().signal,
ports
);
await executePresentationCommand(
{ action: "clear_highlights" },
new AbortController().signal,
ports
);
expect(history.visible.charts).toHaveLength(0);
expect(history.current.charts).toHaveLength(1);
expect(history.current.highlights.features).toHaveLength(0);
expect(scene.clearHighlights).not.toHaveBeenCalled();
expect(scene.highlight).toHaveBeenCalledTimes(1);
renderPresentationSnapshot(
scene as unknown as InvestigationMapPresentation,
map as unknown as MapLibreMap,
history.visible
);
expect(scene.highlight).toHaveBeenLastCalledWith(
expect.objectContaining({
features: [expect.objectContaining({ properties: { device_id: "P1" } })]
}),
false,
false
);
expect(map.easeTo).toHaveBeenLastCalledWith(
expect.objectContaining({ center: [121.48, 31.1], zoom: 18, duration: 450 })
);
expect(controller.queryPresentationFeatures).toHaveBeenCalledTimes(1);
history.select(null);
renderPresentationSnapshot(
scene as unknown as InvestigationMapPresentation,
map as unknown as MapLibreMap,
history.visible
);
expect(scene.highlight).toHaveBeenCalledTimes(2);
expect(controller.queryPresentationFeatures).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,189 @@
import type { FeatureCollection } from "geojson";
import { LngLat, type Map as MapLibreMap, type PaddingOptions } from "maplibre-gl";
import type { PresentationPorts } from "./presentation-command-executor";
import {
investigationGeometryCoordinates,
type InvestigationMapPresentation
} from "./investigation-map-presentation";
import { createScadaAnalysisCollection } from "./scada-analysis";
import type { WorkbenchMapController } from "./workbench-map-controller";
import type { PresentationCamera, PresentationView } from "../utils/presentation-view-history";
export function capturePresentationCamera(map: MapLibreMap | null): PresentationCamera | null {
return map
? {
center: map.getCenter().toArray() as [number, number],
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch()
}
: null;
}
export function fitPresentationCamera(
map: MapLibreMap | null,
data: FeatureCollection,
padding: PaddingOptions
): PresentationCamera | null {
if (!map) return null;
const positions = data.features.flatMap((feature) =>
feature.geometry ? investigationGeometryCoordinates(feature.geometry) : []
);
if (!positions.length) return capturePresentationCamera(map);
const bounds = positions.reduce(
(value, point) => [
Math.min(value[0], point[0]),
Math.min(value[1], point[1]),
Math.max(value[2], point[0]),
Math.max(value[3], point[1])
],
[Infinity, Infinity, -Infinity, -Infinity]
);
const camera = map.cameraForBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]]
],
{ padding, maxZoom: 18 }
);
return camera
? {
center: camera.center
? (LngLat.convert(camera.center).toArray() as [number, number])
: map.getCenter().toArray(),
zoom: camera.zoom ?? map.getZoom(),
bearing: camera.bearing ?? map.getBearing(),
pitch: map.getPitch()
}
: capturePresentationCamera(map);
}
type Options = {
controller: Pick<WorkbenchMapController, "queryPresentationFeatures">;
scene: InvestigationMapPresentation;
map: () => MapLibreMap | null;
padding: () => PaddingOptions;
update: (updater: (view: PresentationView) => PresentationView) => void;
rendering: () => boolean;
cameraAllowed: () => boolean;
waitForChart: (id: string, signal: AbortSignal) => Promise<void>;
frame: (signal: AbortSignal) => Promise<void>;
};
/** Live commands keep updating current while the visible renderer is pinned to a past snapshot. */
export function presentationHistoryPorts(options: Options): PresentationPorts {
const { scene, update, rendering } = options;
return {
controller: options.controller,
cameraAllowed: options.cameraAllowed,
scene: {
highlight(data, pulse, fit) {
update((view) => ({
...view,
highlights: structuredClone(data),
camera: fit ? fitPresentationCamera(options.map(), data, options.padding()) : view.camera
}));
if (rendering()) scene.highlight(data, pulse, fit);
},
clearHighlights() {
update((view) => ({
...view,
highlights: { type: "FeatureCollection", features: [] },
highlightLayers: []
}));
if (rendering()) scene.clearHighlights();
},
setOverlay(overlay, fit) {
update((view) => ({
...view,
overlay: structuredClone(overlay),
camera: fit
? fitPresentationCamera(options.map(), overlay.data, options.padding())
: view.camera
}));
if (rendering()) scene.setOverlay(overlay, fit);
},
clearOverlay(id) {
update((view) => ({
...view,
overlay: !id || view.overlay?.overlay_id === id ? null : view.overlay
}));
if (rendering()) scene.clearOverlay(id);
},
renderScadaAnalysis(data, items) {
const next = createScadaAnalysisCollection(data, items);
if (!next.result.rendered_ids.length) throw new Error("SCADA_FEATURES_NOT_FOUND");
update((view) => ({
...view,
scada: { data: structuredClone(data), items: structuredClone(items) },
camera: fitPresentationCamera(options.map(), next.collection, options.padding())
}));
return rendering()
? scene.renderScadaAnalysis(data, items)
: { ...next.result, fitted: false };
},
clearScadaAnalysis() {
update((view) => ({ ...view, scada: null }));
return rendering() ? scene.clearScadaAnalysis() : { cleared: true as const };
}
},
highlightsChanged: (highlightLayers) => update((view) => ({ ...view, highlightLayers })),
overlayChanged: () => undefined,
zoom: async (center, zoom, signal) => {
const map = options.map();
if (!map) throw new Error("MAP_NOT_READY");
update((view) => ({
...view,
camera: {
center: [...center],
zoom,
bearing: view.camera?.bearing ?? map.getBearing(),
pitch: view.camera?.pitch ?? map.getPitch()
}
}));
if (rendering()) {
map.stop();
map.easeTo({
center,
zoom,
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 450
});
}
await options.frame(signal);
},
openChart: async (chart, signal) => {
update((view) => ({
...view,
charts: [
...view.charts.filter((item) => item.chart_id !== chart.chart_id),
structuredClone(chart)
]
}));
if (rendering()) await options.waitForChart(chart.chart_id, signal);
else await options.frame(signal);
},
closeChart: async (id, signal) => {
update((view) => ({ ...view, charts: view.charts.filter((chart) => chart.chart_id !== id) }));
await options.frame(signal);
}
};
}
/** Restore captured geometry directly; history navigation never calls an original query or command. */
export function renderPresentationSnapshot(
scene: InvestigationMapPresentation,
map: MapLibreMap,
snapshot: PresentationView
) {
scene.clear();
if (snapshot.highlights.features.length) scene.highlight(snapshot.highlights, false, false);
if (snapshot.overlay) scene.setOverlay(snapshot.overlay, false);
if (snapshot.scada) scene.renderScadaAnalysis(snapshot.scada.data, snapshot.scada.items, false);
if (snapshot.camera) {
map.stop();
map.easeTo({
...snapshot.camera,
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 450
});
}
}
@@ -7,8 +7,19 @@ import {
showFlowOverlay
} from "./flow-overlay";
import { WorkbenchMapController } from "./workbench-map-controller";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
describe("WorkbenchMapController", () => {
it("tags presentation geometry with its verified source without changing manual state or cached features", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
const data = await controller.queryPresentationFeatures("junctions", ["J-1"], new AbortController().signal);
expect(data.features[0].properties).toEqual({ id: "J-1", [PRESENTATION_FEATURE_SOURCE]: "junctions" });
expect(pointCollection.features[0].properties).toEqual({ id: "J-1" });
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([]);
});
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
@@ -1,6 +1,7 @@
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import {
LAYER_GROUP_GEOMETRIES,
applyLayerGroupStyle as applyStyle,
@@ -152,14 +153,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
/** Resolve a task-owned overlay without changing the user's selection or controller highlight. */
async queryPresentationFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal: AbortSignal) {
async queryPresentationFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal: AbortSignal): Promise<FeatureCollection> {
const map = this.options.getMap();
if (!map || !this.options.isReady()) throw new Error("MAP_NOT_READY");
if (!map.getSource(sourceId) || this.availableSourceIds && !this.availableSourceIds.has(sourceId)) throw new Error("FEATURE_NOT_FOUND");
const ids = new Set(featureIds);
const collection = await this.fetchFeatures(sourceId, [...ids], signal);
signal.throwIfAborted();
return { ...collection, features: collection.features.filter((feature) => ids.has(String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id))) };
return { ...collection, features: collection.features
.filter((feature) => ids.has(String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id)))
.map((feature) => ({ ...feature, properties: { ...feature.properties, [PRESENTATION_FEATURE_SOURCE]: sourceId } })) };
}
private moveCameraToFeature(map: MapLibreMap, feature: Feature, durationOverride?: number) {
@@ -29,7 +29,7 @@ const workOrder = (revision: number, overrides: Partial<PeripheralWorkOrder> = {
priority: "high",
assignee: null,
scheduled_at: null,
origin: "synthetic",
origin: "system_data",
work_order_type: "general",
location: null,
scenario_code: null,
@@ -43,6 +43,7 @@ const workOrder = (revision: number, overrides: Partial<PeripheralWorkOrder> = {
});
const createWorkOrderInput: Omit<PeripheralCreateWorkOrder, "request_id"> = {
origin: "system_data",
title: "演示 测试工单",
summary: "外围联调",
status: "in_progress",
@@ -256,8 +256,9 @@ function isManagedResult(value: unknown): value is PeripheralWorkOrder | Periphe
const item = value as Record<string, unknown>;
if (item.deleted === true) return typeof item.resource_id === "string" &&
(item.resource_type === "work_order" || item.resource_type === "test_data_record");
return (typeof item.work_order_id === "string" || typeof item.record_id === "string") &&
typeof item.revision === "number" && item.origin === "synthetic";
if (typeof item.revision !== "number") return false;
if (typeof item.work_order_id === "string") return item.origin === "system_data";
return typeof item.record_id === "string" && item.origin === "synthetic";
}
export class PeripheralSampleError extends Error {
@@ -266,7 +266,7 @@ export interface components {
* Origin
* @constant
*/
origin: "synthetic";
origin: "system_data";
/**
* Project Id
* Format: uuid
@@ -329,6 +329,12 @@ export interface components {
feedback_requirements?: string[];
/** Location */
location?: string | null;
/**
* Origin
* @default system_data
* @constant
*/
origin: "system_data";
/**
* Priority
* @default normal
@@ -961,7 +967,7 @@ export interface components {
* Origin
* @constant
*/
origin: "synthetic";
origin: "system_data";
/**
* Priority
* @enum {string}
@@ -1,7 +1,7 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
CalendarClock,
ClipboardList,
LoaderCircle,
Pencil,
Plus,
@@ -18,7 +18,7 @@ import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { WorkbenchNotice } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types";
import type {
PeripheralContext,
@@ -67,22 +67,20 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
};
return (
<WorkbenchSection
title="工单接口测试"
description="分别验证预约工单、投诉与爆管事件工单及后端同步。"
icon={<ClipboardList size={15} />}
tone="teal"
bodyClassName="space-y-3"
actions={(
<Button size="icon-sm" variant="ghost" aria-label="刷新测试工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
<section aria-label="工单管理" className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold">工单管理</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">预约安排、投诉调查与爆管处置,按类型查看和维护。</p>
</div>
<Button size="icon-sm" variant="ghost" aria-label="刷新工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
<RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} />
</Button>
)}
>
</div>
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
{capabilities.work_orders !== "available" ? (
<WorkbenchNotice tone="warning" title="测试工单存储待供给">
缺少 peripheral_sample.work_orders 或 work_order_revisions。
<WorkbenchNotice tone="warning" title="工单服务未就绪">
当前项目尚未配置工单存储,请联系管理员。
</WorkbenchNotice>
) : (
<WorkOrders
@@ -94,7 +92,7 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
onAgentTrigger={onAgentTrigger}
/>
)}
</WorkbenchSection>
</section>
);
}
@@ -210,7 +208,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
scheduled_at: new Date(eventOccurredAt).toISOString(),
work_order_type: eventKind,
location: eventLocation.trim(),
scenario_code: waterQuality ? "WQ-SECONDARY-SUPPLY-001" : "BURST-REPORT-001",
scenario_code: null,
origin: "system_data",
feedback_requirements: []
});
setEventEditorOpen(false);
@@ -220,16 +219,17 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
});
const canSave = canWrite && !busy && title.trim() && summary.trim();
const editorInputClassName = "mt-1 h-9 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)]";
const editorInputClassName = "mt-1 h-9 rounded-lg border-border bg-card";
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
const orderEditor = editorOpen ? (
<section
id={editing ? `work-order-editor-${editing.work_order_id}` : "new-work-order-editor"}
aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"}
className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200"
className="mt-4 border-t border-border pt-4"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) {
if (event.key === "Escape" && !busy && !event.defaultPrevented
&& event.currentTarget.contains(event.target as Node)) {
event.preventDefault();
event.stopPropagation();
closeEditor();
@@ -238,19 +238,19 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
>
<EditorHeading title={editing ? "编辑工单" : "新增预约工单"} onClose={closeEditor} disabled={Boolean(busy)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">标题<Input ref={titleInputRef} className={editorInputClassName} value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
<FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-slate-600">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-slate-600">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">标题<Input ref={titleInputRef} className={editorInputClassName} value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<FieldSelect contentClassName="workbench-shadcn-surface" label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
<FieldSelect contentClassName="workbench-shadcn-surface" label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-muted-foreground">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-muted-foreground">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField contentClassName="workbench-shadcn-surface" label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-muted-foreground">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
</div>
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
{editing ? <p className="mt-2 text-[11px] text-muted-foreground">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
<label className="mt-3 block text-xs font-medium text-muted-foreground">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-muted-foreground">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
@@ -282,6 +282,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
assignee: assignee.trim() || null,
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
work_order_type: "general",
origin: "system_data",
location: location.trim() || null,
scenario_code: scenarioCode.trim() || null,
feedback_requirements: requirements
@@ -320,11 +321,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h4 className="text-xs font-semibold text-slate-800">分类工单</h4>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
</div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
<Sparkles size={13} />投诉与爆管上报
@@ -333,17 +330,18 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<Plus size={13} />新增预约工单
</Button>
</div>
<p className="text-xs leading-5 text-muted-foreground">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
</div>
{eventEditorOpen ? (
<div className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3 sm:p-4">
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<FieldSelect label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
<DateTimeField label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
<FieldSelect contentClassName="workbench-shadcn-surface" label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
<DateTimeField contentClassName="workbench-shadcn-surface" label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
</div>
<label className="mt-2 block text-xs font-medium text-slate-600">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-slate-600">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-muted-foreground">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || !eventLocation.trim() || !eventDescription.trim()} onClick={createEventWorkOrder}>
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
@@ -364,25 +362,25 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
{editorOpen && !editing ? orderEditor : null}
<div aria-label="分类工单列表" className="mt-3 space-y-3">
<div aria-label="分类工单列表" className="mt-4 divide-y divide-border">
{orderGroups.map((group) => (
<Disclosure
key={group.id}
aria-label={`${group.title}列表`}
initiallyOpen
className="rounded-xl border border-slate-200/90 bg-white px-3"
className="py-3 [&>summary]:rounded-md [&>summary]:py-1"
label={(
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h5 className="text-xs font-semibold text-slate-800">{group.title}</h5>
<p className="mt-0.5 text-[11px] font-normal leading-4 text-slate-500">{group.description}</p>
<h5 className="text-sm font-semibold text-foreground">{group.title}</h5>
<p className="mt-0.5 text-[11px] font-normal leading-4 text-muted-foreground">{group.description}</p>
</div>
<StatusBadge tone="neutral">{group.items.length}</StatusBadge>
</div>
)}
>
{group.items.length ? (
<ol className="space-y-2 border-t border-slate-200/80 py-3">
<ol className="space-y-3 pt-3">
{group.items.map((item, index) => (
<li key={item.work_order_id}>
<WorkOrderCard
@@ -406,7 +404,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
))}
</ol>
) : (
<p className="mb-3 border-t border-slate-200/80 bg-slate-50 px-3 py-2 text-[11px] text-slate-500">暂无{group.title}</p>
<p className="py-4 text-xs text-muted-foreground">暂无{group.title}</p>
)}
</Disclosure>
))}
@@ -435,16 +433,16 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
deleteButtonRef.current?.focus();
};
return (
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
<article className="rounded-lg border border-border bg-card p-3 sm:p-4">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold text-slate-400">{workOrderTypeLabel(item.work_order_type)} {String(index + 1).padStart(2, "0")}</span>
<span className="text-[10px] font-semibold text-muted-foreground">#{String(index + 1).padStart(2, "0")}</span>
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
</div>
<h6 className="mt-1 text-balance text-xs font-semibold text-slate-900">{item.title}</h6>
<p className="mt-1 text-[11px] leading-4 text-slate-500">{item.summary}</p>
<h6 className="mt-1 text-balance text-xs font-semibold text-foreground">{item.title}</h6>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{item.summary}</p>
</div>
{!editing ? <div className="flex shrink-0 gap-0.5">
<Button id={`work-order-edit-${item.work_order_id}`} size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={(event) => onEdit(event.currentTarget)}><Pencil size={14} /></Button>
@@ -477,15 +475,19 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
</div>
) : null}
{editor}
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-200/80 pt-2 text-[10px] text-slate-500">
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border pt-2 text-[10px] text-muted-foreground">
<span>{priorityLabel(item.priority)}优先级</span>
{item.location ? <span>位置:{item.location}</span> : null}
{item.assignee ? <span>负责人:{item.assignee}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatTimestamp(item.scheduled_at)}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatLocalMonthDayTime(item.scheduled_at)}</span> : null}
<span className="font-mono">r{item.revision}</span>
</div>
<div className="mt-3">
{workOrder ? <WorkOrderReportPanel condition={workOrder} /> : <EventWorkOrderDetails item={item} />}
{workOrder ? (
<Disclosure aria-label={`工单详情 ${item.title}`} label="查看工单详情" className="border-t border-border pt-1">
<WorkOrderReportPanel condition={workOrder} />
</Disclosure>
) : <EventWorkOrderDetails item={item} />}
</div>
</article>
);
@@ -493,33 +495,33 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
function EventWorkOrderDetails({ item }: { item: PeripheralWorkOrder }) {
return (
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-[10px] font-semibold text-slate-500">事件信息</p>
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="border-t border-border pt-3">
<p className="text-[10px] font-semibold text-muted-foreground">事件信息</p>
<dl className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
<EventWorkOrderField label="事件类型" value={workOrderTypeLabel(item.work_order_type)} />
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatTimestamp(item.scheduled_at) : "未记录"} />
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatLocalMonthDayTime(item.scheduled_at) : "未记录"} />
<EventWorkOrderField label="位置" value={item.location ?? "未填写"} />
<EventWorkOrderField label="场景编号" value={item.scenario_code ?? "未填写"} />
<EventWorkOrderField label="处理人" value={item.assignee ?? "待分配"} />
<EventWorkOrderField label="活动记录" value={`${item.activity?.length ?? 0} 条`} />
</dl>
{item.activity?.length ? (
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-slate-200 pt-3">
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-border pt-3">
{item.activity.map((entry) => (
<li key={entry.sequence} className="rounded-lg bg-slate-50 px-2.5 py-2 text-[11px] leading-4 text-slate-600">
<span className="font-semibold text-slate-800">{entry.title}</span>
<li key={entry.sequence} className="rounded-lg bg-muted/50 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
<span className="font-semibold text-foreground">{entry.title}</span>
<span className="ml-2">{entry.detail}</span>
<span className="mt-1 block text-[10px] text-slate-400">{formatTimestamp(entry.occurred_at)} · {entry.actor}</span>
<span className="mt-1 block text-[10px] text-muted-foreground">{formatLocalMonthDayTime(entry.occurred_at)} · {entry.actor}</span>
</li>
))}
</ol>
) : <p className="mt-3 border-t border-slate-200 pt-3 text-[11px] text-slate-500">暂无事件活动记录</p>}
) : <p className="mt-3 border-t border-border pt-3 text-[11px] text-muted-foreground">暂无事件活动记录</p>}
</section>
);
}
function EventWorkOrderField({ label, value }: { label: string; value: string }) {
return <div className="min-w-0 rounded-lg bg-slate-50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-slate-500">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-slate-800">{value}</dd></div>;
return <div className="min-w-0 rounded-lg bg-muted/50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-muted-foreground">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-foreground">{value}</dd></div>;
}
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
@@ -549,7 +551,7 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
}
function EditorHeading({ title, onClose, disabled = false }: { title: string; onClose: () => void; disabled?: boolean }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-foreground">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
}
const WORK_ORDER_STATUS_OPTIONS = [
@@ -566,9 +568,7 @@ function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function formatTimestamp(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
function message(error: unknown) { return error instanceof Error ? error.message : "外围工单请求失败"; }
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
@@ -16,7 +16,7 @@ import {
type PeripheralWorkOrder
} from "./client";
const DISCLAIMER = "仅用于验证外围工单接口与同步过程,不代表真实现场处置";
const WORK_ORDER_DESCRIPTION = "录入业务工单并跟踪处理反馈,事件调查依据实际查询和现场记录开展。";
export function PeripheralSamplePanel({
open,
@@ -64,15 +64,15 @@ export function PeripheralSamplePanel({
return (
<aside
aria-label="外围系统"
className="acrylic-panel absolute bottom-3 right-3 top-16 z-[61] flex w-[min(680px,calc(100%-1.5rem))] flex-col overflow-hidden overscroll-contain rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)] max-sm:bg-[var(--surface-reading)]"
className="workbench-shadcn-surface bg-background text-foreground absolute bottom-3 right-3 top-16 z-[61] flex w-[min(680px,calc(100%-1.5rem))] flex-col overflow-hidden overscroll-contain rounded-2xl border shadow-lg"
>
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b border-slate-200/80 px-4 sm:px-5">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-cyan-700 text-white">
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b border-border px-4 sm:px-5">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-muted text-foreground">
<RadioTower size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">外围系统</h2>
<p className="text-xs text-slate-500">工单协同与 SCADA 最新数据</p>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 最新数据</p>
</div>
<Button
size="icon"
@@ -96,8 +96,8 @@ export function PeripheralSamplePanel({
onValueChange={(value) => setActiveSystem(value as "work-orders" | "scada")}
className="flex min-h-0 flex-1 flex-col"
>
<div className="shrink-0 border-b border-[var(--surface-divider)] px-3 py-2 sm:px-4">
<TabsList aria-label="外围系统类型" className="grid w-full grid-cols-2 bg-[var(--surface-control)]">
<div className="shrink-0 border-b border-border px-3 py-2 sm:px-4">
<TabsList aria-label="外围系统类型" className="grid w-full grid-cols-2 bg-muted">
<TabsTrigger value="work-orders"><ClipboardList size={14} />工单系统</TabsTrigger>
<TabsTrigger value="scada"><Activity size={14} />SCADA 系统</TabsTrigger>
</TabsList>
@@ -105,7 +105,10 @@ export function PeripheralSamplePanel({
<TabsContent value="work-orders" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
<div className="mb-3 grid gap-2">
<WorkbenchNotice tone="info" title="合成工单环境">{DISCLAIMER}</WorkbenchNotice>
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
<p className="font-medium">外围工单系统</p>
<p className="text-muted-foreground">{WORK_ORDER_DESCRIPTION}</p>
</div>
{!client.configured ? (
<WorkbenchNotice tone="warning">
未配置 TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL,当前不会连接外围工单服务。
@@ -128,9 +131,9 @@ export function PeripheralSamplePanel({
/>
<Disclosure
label={<span className="inline-flex items-center gap-2"><ReceiptText size={14} />按 request_id 找回提交结果</span>}
className="rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"
className="rounded-lg border border-border bg-background px-3.5"
>
<p className="pb-2 text-xs leading-5 text-slate-500">
<p className="pb-2 text-xs leading-5 text-muted-foreground">
结果未知时只查询原请求,不会重复创建或更新工单。
</p>
<div className="flex gap-2 pb-3">
@@ -139,7 +142,7 @@ export function PeripheralSamplePanel({
value={receiptRequestId}
onChange={(event) => setReceiptRequestId(event.target.value.trim())}
placeholder="request_id"
className="h-10 min-w-0 flex-1 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)] font-mono text-xs md:text-xs"
className="h-10 min-w-0 flex-1 rounded-lg border-border bg-background font-mono text-xs md:text-xs"
/>
<Button
variant="outline"
@@ -185,9 +185,9 @@ export function ScadaSystemSection({
return (
<div className="space-y-3" lang="zh-CN">
<WorkbenchNotice tone="info" title="项目 SCADA 数据源">
展示当前项目中每个设备最后一条已持久化记录。页面可见时每 15 秒刷新,不据此推断设备在线状态。
</WorkbenchNotice>
<p aria-label="SCADA 数据说明" className="text-xs leading-5 text-muted-foreground">
当前项目各设备最后一条持久化记录,每 15 秒刷新,不据此推断设备在线状态。
</p>
{error ? (
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新">
{error}。已加载的数据会继续保留。
@@ -213,25 +213,112 @@ export function ScadaSystemSection({
<section
aria-label="SCADA 最新数据概览"
className="surface-control grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-[var(--surface-divider)] px-1 py-3"
className="bg-muted grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-border px-1 py-3"
>
<div className="px-3">
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">已取得最新记录</p>
<p className="mt-1 text-xl font-semibold leading-7 text-slate-900 tabular-nums">
<p className="text-[11px] leading-4 text-muted-foreground">已取得最新记录</p>
<p className="mt-1 text-xl font-semibold leading-7 text-foreground tabular-nums">
{snapshot ? snapshot.observed_count : "—"}
<span className="ml-1 text-xs font-medium text-[var(--ink-muted)]">
<span className="ml-1 text-xs font-medium text-muted-foreground">
/ {snapshot ? snapshot.total : "—"} 个设备
</span>
</p>
</div>
<div className="px-3">
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">最近记录时间</p>
<p className="mt-1 text-sm font-semibold leading-7 text-slate-900 tabular-nums">
<p className="text-[11px] leading-4 text-muted-foreground">最近记录时间</p>
<p className="mt-1 text-sm font-semibold leading-7 text-foreground tabular-nums">
{formatObservedTime(snapshot?.latest_observed_at ?? null)}
</p>
</div>
</section>
<WorkbenchSection
title="最新测点值"
description={refreshedAt
? `本页读取于 ${formatClock(refreshedAt)},按设备编号排序`
: busy ? "正在读取设备目录与最后一条记录" : "尚未读取"}
icon={<Activity size={15} />}
tone="teal"
meta={busy ? <StatusBadge tone="info" activity="loading">更新中</StatusBadge> : null}
bodyClassName="p-0"
>
<div className="border-b border-border p-3">
<div className="relative">
<Search
size={14}
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="筛选 SCADA 设备"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="筛选设备编号、类型或单位"
className="h-10 bg-card pl-9 text-xs md:text-xs"
/>
</div>
</div>
{!snapshot && busy ? (
<p role="status" className="px-4 py-8 text-center text-xs text-muted-foreground">
正在读取 SCADA 最新数据
</p>
) : visibleItems.length === 0 ? (
<WorkbenchEmptyState
icon={<Database size={16} />}
title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"}
description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"}
className="m-3"
/>
) : (
<div className="max-h-80 overflow-auto overscroll-contain">
<table className="w-full min-w-[620px] border-collapse text-xs" aria-label="SCADA 最新数据表">
<thead className="sticky top-0 z-10 bg-muted text-[11px] text-muted-foreground">
<tr className="border-b border-border">
<th scope="col" className="px-3 py-2.5 text-left font-medium">设备</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">原始值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">清洗值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">记录时间</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item) => (
<tr key={item.device_id} className="border-b border-border last:border-b-0">
<th scope="row" className="px-3 py-2.5 text-left font-medium text-foreground">
<span className="block font-mono text-[12px]">{item.device_id}</span>
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
</span>
</th>
<td className="px-3 py-2.5 text-right font-mono text-foreground tabular-nums">
{formatValue(item.monitored_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right font-mono text-muted-foreground tabular-nums">
{formatValue(item.cleaned_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{item.observed_at ? (
<time dateTime={item.observed_at} className="text-foreground">
{formatObservedTime(item.observed_at)}
</time>
) : (
<StatusBadge tone="neutral">无记录</StatusBadge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{snapshot && snapshot.total > snapshot.items.length ? (
<p className="border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
</p>
) : null}
</WorkbenchSection>
<WorkbenchSection
title="异常读数演练"
description="读数写入独立合成数据区,可据此建立 Agent 调查任务,不改写正式 SCADA 时序表。"
@@ -258,9 +345,10 @@ export function ScadaSystemSection({
) : null}
{simulationEditorOpen && canSimulate ? (
<div className="rounded-xl bg-[var(--surface-control)] p-3 ring-1 ring-inset ring-[var(--surface-divider)]">
<div className="border-b border-border pb-4">
<div className="grid gap-3 sm:grid-cols-2">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="SCADA 设备"
value={simulationPointId}
onValueChange={setSimulationPointId}
@@ -271,18 +359,19 @@ export function ScadaSystemSection({
}))}
/>
<DateTimeField
contentClassName="workbench-shadcn-surface"
label="模拟发生时间"
value={simulationObservedAt}
onChange={setSimulationObservedAt}
disabled={simulationBusy}
/>
</div>
<label className="mt-3 block text-xs font-medium text-slate-600">
<label className="mt-3 block text-xs font-medium text-muted-foreground">
模拟读数{selectedSimulationDevice?.measurement_unit
? `(${selectedSimulationDevice.measurement_unit})`
: ""}
<Input
className="mt-1.5 h-10 bg-[var(--surface-reading)] text-sm tabular-nums"
className="mt-1.5 h-10 bg-card text-sm tabular-nums"
inputMode="decimal"
value={simulationValue}
onChange={(event) => setSimulationValue(event.target.value)}
@@ -291,17 +380,17 @@ export function ScadaSystemSection({
aria-invalid={simulationValue.trim() !== "" && !Number.isFinite(parsedSimulationValue)}
/>
</label>
<label className="mt-3 block text-xs font-medium text-slate-600">
<label className="mt-3 block text-xs font-medium text-muted-foreground">
异常说明
<Textarea
className="mt-1.5 min-h-20 bg-[var(--surface-reading)]"
className="mt-1.5 min-h-20 bg-card"
value={simulationReason}
onChange={(event) => setSimulationReason(event.target.value)}
disabled={simulationBusy}
placeholder="例如:压力突降,偏离近一小时稳定区间"
/>
</label>
<p className="mt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
<p className="mt-2 text-[11px] leading-5 text-muted-foreground">
读数按“有效但业务异常”保存。保存并触发后,Agent 会收到点位、数值、单位、发生时间和异常说明。
</p>
<div className="mt-3 flex flex-wrap gap-2">
@@ -330,15 +419,15 @@ export function ScadaSystemSection({
{simulations.slice(0, 5).map((item) => (
<li
key={item.record_id}
className="flex min-w-0 items-start gap-3 rounded-xl bg-[var(--surface-control)] px-3 py-2.5 ring-1 ring-inset ring-[var(--surface-divider)]"
className="flex min-w-0 items-start gap-3 rounded-xl bg-muted px-3 py-2.5 ring-1 ring-inset ring-[var(--surface-divider)]"
>
<StatusBadge tone="warning">模拟异常</StatusBadge>
<div className="min-w-0 flex-1">
<p className="font-mono text-xs font-semibold text-slate-900">{item.point_id}</p>
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
<p className="font-mono text-xs font-semibold text-foreground">{item.point_id}</p>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{formatValue(item.value, item.unit)} · {formatObservedTime(item.observed_at)}
</p>
<p className="mt-1 break-words text-[11px] leading-4 text-slate-700">
<p className="mt-1 break-words text-[11px] leading-4 text-foreground">
{item.notes.replace(SCADA_ANOMALY_MARKER, "").trim()}
</p>
</div>
@@ -346,96 +435,10 @@ export function ScadaSystemSection({
))}
</ol>
) : canSimulate && !simulationEditorOpen ? (
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">尚未设定 SCADA 模拟异常。</p>
<p className="text-[11px] leading-5 text-muted-foreground">尚未设定 SCADA 模拟异常。</p>
) : null}
</WorkbenchSection>
<WorkbenchSection
title="最新测点值"
description={refreshedAt
? `本页读取于 ${formatClock(refreshedAt)},按设备编号排序`
: busy ? "正在读取设备目录与最后一条记录" : "尚未读取"}
icon={<Activity size={15} />}
tone="teal"
meta={busy ? <StatusBadge tone="info" activity="loading">更新中</StatusBadge> : null}
bodyClassName="p-0"
>
<div className="border-b border-[var(--surface-divider)] p-3">
<div className="relative">
<Search
size={14}
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--ink-muted)]"
/>
<Input
aria-label="筛选 SCADA 设备"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="筛选设备编号、类型或单位"
className="h-10 bg-[var(--surface-reading)] pl-9 text-xs md:text-xs"
/>
</div>
</div>
{!snapshot && busy ? (
<p role="status" className="px-4 py-8 text-center text-xs text-[var(--ink-muted)]">
正在读取 SCADA 最新数据
</p>
) : visibleItems.length === 0 ? (
<WorkbenchEmptyState
icon={<Database size={16} />}
title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"}
description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"}
className="m-3"
/>
) : (
<div className="max-h-[calc(100dvh-390px)] min-h-48 overflow-auto overscroll-contain">
<table className="w-full min-w-[620px] border-collapse text-xs" aria-label="SCADA 最新数据表">
<thead className="sticky top-0 z-10 bg-[var(--surface-control)] text-[11px] text-[var(--ink-muted)]">
<tr className="border-b border-[var(--surface-divider)]">
<th scope="col" className="px-3 py-2.5 text-left font-medium">设备</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">原始值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">清洗值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">记录时间</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item) => (
<tr key={item.device_id} className="border-b border-[var(--surface-divider)]/70 last:border-b-0">
<th scope="row" className="px-3 py-2.5 text-left font-medium text-slate-800">
<span className="block font-mono text-[12px]">{item.device_id}</span>
<span className="mt-0.5 block text-[11px] font-normal text-[var(--ink-muted)]">
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
</span>
</th>
<td className="px-3 py-2.5 text-right font-mono text-slate-900 tabular-nums">
{formatValue(item.monitored_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right font-mono text-[var(--ink-muted)] tabular-nums">
{formatValue(item.cleaned_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{item.observed_at ? (
<time dateTime={item.observed_at} className="text-slate-700">
{formatObservedTime(item.observed_at)}
</time>
) : (
<StatusBadge tone="neutral">无记录</StatusBadge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{snapshot && snapshot.total > snapshot.items.length ? (
<p className="border-t border-[var(--surface-divider)] px-3 py-2 text-[11px] text-[var(--ink-muted)]">
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
</p>
) : null}
</WorkbenchSection>
</div>
);
}
@@ -1,84 +0,0 @@
import { useMemo, useState } from "react";
import { Database, LoaderCircle } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { FieldSelect } from "@/shared/ui/field-select";
import { Disclosure } from "@/shared/ui/disclosure";
import {
PeripheralSampleClient,
type PeripheralDemoDataset
} from "./client";
export function SyntheticDemoSection({ client }: { client: PeripheralSampleClient }) {
const [dataset, setDataset] = useState<PeripheralDemoDataset | null>(null);
const [pointId, setPointId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const observations = useMemo(
() => dataset?.observations.filter((item) => item.point_id === pointId) ?? [],
[dataset, pointId]
);
const load = async () => {
setLoading(true);
setError(null);
try {
const next = await client.getDemoData();
setDataset(next);
setPointId((current) => current || next.points[0]?.point_id || "");
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "合成演示样本读取失败");
} finally {
setLoading(false);
}
};
return (
<Disclosure
label={<span className="flex min-w-0 items-center gap-3"><span className="grid size-7 shrink-0 place-items-center rounded-lg bg-cyan-50 text-cyan-800"><Database size={15} /></span><span className="min-w-0"><span className="block text-sm font-semibold text-slate-900">合成演示样本</span><span className="block truncate text-xs font-normal text-slate-500">固定种子 · 虚拟资产 · 非控制指令</span></span></span>}
className="overflow-hidden rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] px-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.06)] [&>summary]:min-h-14 [&>summary]:py-3"
>
<div className="-mx-3.5 border-t border-slate-200/80 p-3.5">
<p className="text-xs leading-5 text-slate-600">样本由外围服务从持久化数据集读取,浏览器不会生成或写入数据。所有资产、告警和运行事件均用于演示,不代表现场状态,也不是控制指令。</p>
<Button variant="outline" className="mt-3 w-full" disabled={loading} onClick={() => void load()}>
{loading ? <LoaderCircle className="animate-spin" /> : <Database />}
{dataset ? "重新读取固定样本" : "手动读取合成样本"}
</Button>
{error ? <p className="mt-2 rounded-lg bg-rose-50 p-2.5 text-xs leading-5 text-rose-900 ring-1 ring-inset ring-rose-200">{error}。真实历史回放与核查管理不受影响。</p> : null}
{dataset ? <DemoDatasetView dataset={dataset} pointId={pointId} onPointChange={setPointId} observations={observations} /> : null}
</div>
</Disclosure>
);
}
function DemoDatasetView({ dataset, pointId, onPointChange, observations }: {
dataset: PeripheralDemoDataset;
pointId: string;
onPointChange: (value: string) => void;
observations: PeripheralDemoDataset["observations"];
}) {
return <div className="mt-3 space-y-3">
<div className="rounded-xl border border-cyan-200 bg-cyan-50/65 p-3 text-xs text-cyan-950">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Meta label="性质" value="合成演示" />
<Meta label="生成器" value={dataset.generator_version} />
<Meta label="固定种子" value={dataset.seed} />
<Meta label="来源修订" value={dataset.source_revision} />
</div>
<p className="mt-2 break-words leading-5">固定窗口:{formatTimestamp(dataset.window.start)} 至 {formatTimestamp(dataset.window.end)}</p>
<p className="mt-1 leading-5">摘要:{dataset.points.length} 个虚拟测点,{dataset.observations.length} 条读数,{dataset.device_states.length} 条状态,{dataset.alerts.length} 条告警,{dataset.operations.length} 条运行事件。</p>
</div>
<FieldSelect label="虚拟测点" value={pointId} onValueChange={onPointChange} options={dataset.points.map((point) => ({ value: point.point_id, label: `${point.label} · ${point.unit}` }))} />
<div className="max-h-48 overflow-auto rounded-lg bg-white ring-1 ring-slate-200">
<table className="w-full text-left text-[11px]"><thead className="sticky top-0 bg-white text-slate-500"><tr><th className="px-2.5 py-2 font-medium">时间</th><th className="px-2 py-2 text-right font-medium">读数</th><th className="px-2 py-2 font-medium">质量</th></tr></thead><tbody>{observations.map((item) => <tr key={item.record_id} className="border-t border-slate-100"><td className="whitespace-nowrap px-2.5 py-2 tabular-nums">{formatTimestamp(item.observed_at)}</td><td className={cn("whitespace-nowrap px-2 py-2 text-right font-medium tabular-nums", item.value === null && "text-amber-700")}>{item.value === null ? "缺失" : `${item.value} ${item.unit}`}</td><td className="px-2 py-2">{item.quality}</td></tr>)}</tbody></table>
</div>
<DemoList title={`设备状态 ${dataset.device_states.length}`} items={dataset.device_states.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${item.state}`, detail: `${item.asset_type} · ${formatTimestamp(item.observed_at)}` }))} />
<DemoList title={`演示告警 ${dataset.alerts.length}`} items={dataset.alerts.map((item) => ({ id: item.alert_id, title: `${item.asset_id} · ${item.code}`, detail: `${item.message} · ${formatTimestamp(item.occurred_at)}` }))} />
<DemoList title={`运行事件 ${dataset.operations.length}`} items={dataset.operations.map((item) => ({ id: item.record_id, title: `${item.asset_id} · ${operationLabel(item.operation)}`, detail: `${item.note} · ${formatTimestamp(item.occurred_at)}` }))} />
</div>;
}
function Meta({ label, value }: { label: string; value: string }) { return <div><span className="block text-[11px] text-cyan-700">{label}</span><span className="mt-0.5 block break-all font-medium">{value}</span></div>; }
function DemoList({ title, items }: { title: string; items: Array<{ id: string; title: string; detail: string }> }) { return <Disclosure label={title} className="rounded-lg bg-white px-3 py-2 ring-1 ring-slate-200"><div className="mt-2 space-y-2">{items.map((item) => <div key={item.id} className="border-t border-slate-100 pt-2 text-[11px]"><p className="font-medium text-slate-700">{item.title}</p><p className="mt-1 leading-5 text-slate-500">{item.detail}</p></div>)}</div></Disclosure>; }
function formatTimestamp(value: string) { return value.replace("T", " ").replace("Z", " UTC"); }
function operationLabel(value: PeripheralDemoDataset["operations"][number]["operation"]) { return ({ pump_start: "泵启动", pump_stop: "泵停止", valve_open: "阀门开启", valve_close: "阀门关闭" } satisfies Record<typeof value, string>)[value]; }
@@ -19,7 +19,7 @@ const order = (overrides: Partial<PeripheralWorkOrder> = {}): PeripheralWorkOrde
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: WATER_QUALITY_SCENARIO_CODE,
origin: "synthetic",
origin: "system_data",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
created_by: "user",
@@ -38,14 +38,18 @@ describe("water-quality work-order trigger", () => {
expect(first?.trigger_id).toBe(
"peripheral.work_order.created:60000000-0000-4000-8000-000000000001:50000000-0000-4000-8000-000000000001:1"
);
expect(first?.payload.origin).toBe("synthetic");
expect(first?.payload.origin).toBe("system_data");
});
it("does not trigger general, draft, revised, or unknown-scenario work orders", () => {
it("does not trigger general, draft, or revised work orders", () => {
expect(toWaterQualityAgentTrigger(order({ work_order_type: "general" }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ status: "draft" }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ revision: 2 }))).toBeNull();
expect(toWaterQualityAgentTrigger(order({ scenario_code: null }))).toBeNull();
});
it("preserves business provenance without requiring a demo scenario", () => {
const trigger = toWaterQualityAgentTrigger(order({ scenario_code: null }));
expect(trigger?.payload).toMatchObject({ origin: "system_data", scenario_code: null });
});
it("maps the event into a structured condition task linked to the Agent session", () => {
@@ -59,6 +63,7 @@ describe("water-quality work-order trigger", () => {
id: "peripheral-water-quality-50000000-0000-4000-8000-000000000001",
kind: "condition",
taskId: "water-quality-complaint",
dataSource: "backend",
sessionId: "agent-session-water-quality",
status: "running",
riskLevel: "attention",
@@ -10,8 +10,7 @@ export function toWaterQualityAgentTrigger(
if (
workOrder.revision !== 1 ||
workOrder.status !== "open" ||
workOrder.work_order_type !== "water_quality_complaint" ||
workOrder.scenario_code !== WATER_QUALITY_SCENARIO_CODE
workOrder.work_order_type !== "water_quality_complaint"
) {
return null;
}
@@ -35,14 +34,14 @@ export function toWaterQualityAgentTrigger(
summary: workOrder.summary,
location: workOrder.location,
priority: workOrder.priority,
scenario_code: WATER_QUALITY_SCENARIO_CODE,
origin: "synthetic"
scenario_code: workOrder.scenario_code,
origin: "system_data"
}
};
}
export function waterQualityTriggerPrompt(workOrder: PeripheralWorkOrder) {
return `外围样板新增水质投诉工单:${workOrder.title}。请启动水质事件调查。`;
return `外围系统新增水质投诉工单:${workOrder.title}。请启动水质事件调查。`;
}
export function toWaterQualityConditionTask(
@@ -54,7 +53,7 @@ export function toWaterQualityConditionTask(
id: `peripheral-water-quality-${workOrder.work_order_id}`,
kind: "condition",
taskId: "water-quality-complaint",
dataSource: "mock",
dataSource: "backend",
sessionId,
title: "水质投诉调查",
summary: workOrder.summary,
@@ -64,7 +63,7 @@ export function toWaterQualityConditionTask(
updatedAt: now.getTime(),
durationMinutes: 15,
detail: `${workOrder.location ?? "位置待确认"} · 由外围工单事件触发`,
recommendation: "优先校验上游监测点数据质量;若上游指标正常,再转向二次供水设施和运维记录核查。",
recommendation: "根据工单和实际查询结果核查原因,保留数据来源与待现场核验事项。",
evidence: [
`来源工单:${workOrder.work_order_id}`,
`投诉位置:${workOrder.location ?? "待确认"}`,
@@ -481,7 +481,7 @@ export function getFeaturePanelModel(
return {
id,
badge: badgeEntry ? getFeaturePanelBadge(layer, badgeEntry.value) : undefined,
badge: badgeEntry ? getFeaturePanelBadge(badgeEntry.value) : undefined,
attributes: entries.filter((entry) => entry.key !== idKey && entry.key !== badgeKey)
};
}
@@ -625,10 +625,7 @@ function isStatusLikeKey(normalizedKey: string) {
return normalizedKey === "status" || normalizedKey === "setting";
}
function getFeaturePanelBadge(
layer: WaterNetworkSourceId,
value: string
): FeaturePanelBadge {
function getFeaturePanelBadge(value: string): FeaturePanelBadge {
const normalizedValue = value.toLowerCase();
if (["运行", "运行中", "在线", "开启", "正常"].includes(value)) {
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
const center: [number, number] = [121.48, 31.1];
const target = { source_id: "scada", feature_id: "P1" } as const;
describe("switchable investigation Dev cases", () => {
it.each(investigationDevCases.filter((item) => !item.id.startsWith("invalid_")))(
"$label produces a valid, nonempty batch within the existing presentation contract",
({ id }) => {
const input = createInvestigationDevCase(id, center, target);
const preview = parseInvestigationDevJsonl(input);
const records = input.split("\n").map((line) => JSON.parse(line));
expect(records.every((record) => typeof record.record_type === "string")).toBe(true);
expect(
records
.filter((record) => record.record_type === "presentation")
.every((record) => !record.name && !record.params && record.command.action)
).toBe(true);
expect(preview.blocks.every((block) => block.phase === "final")).toBe(true);
expect(
preview.commands.length + preview.blocks.length + preview.records.length
).toBeGreaterThan(0);
}
);
it.each(investigationDevCases.filter((item) => item.id.startsWith("invalid_")))(
"$label cannot produce an executable batch",
({ id }) => {
expect(() => parseInvestigationDevJsonl(createInvestigationDevCase(id, center))).toThrow(
id === "invalid_json" ? "第 2 行不是合法 JSON" : "第 1 行展示指令不合法"
);
}
);
it("only queries an existing task target and falls back to a synthetic map point without one", () => {
const withTarget = parseInvestigationDevJsonl(
createInvestigationDevCase("map_targets", center, target)
);
expect(withTarget.commands[0]).toMatchObject({
action: "highlight_features",
layer: "scada",
feature_ids: ["P1"]
});
const withoutTarget = parseInvestigationDevJsonl(
createInvestigationDevCase("map_targets", center)
);
expect(withoutTarget.commands).toHaveLength(1);
expect(withoutTarget.commands[0]).toMatchObject({ action: "set_overlay", mode: "geometry" });
});
it.each(["data_only", "candidates_table", "valves_table"] as const)(
"%s never turns synthetic asset IDs or embedded action fields into map queries or writes",
(id) => {
const preview = parseInvestigationDevJsonl(createInvestigationDevCase(id, center));
expect(preview.commands).toEqual([]);
expect(preview.records).toHaveLength(2);
expect(preview.records.every((record) => record.origin === "synthetic")).toBe(true);
}
);
it.each([
[180, 90],
[-180, -90],
[540, 85]
] as Array<[number, number]>)(
"keeps synthetic geometry within schema bounds near longitude %s, latitude %s",
(longitude, latitude) => {
for (const id of ["combined", "map_targets", "impact_area", "complaint_heatmap"] as const) {
expect(() =>
parseInvestigationDevJsonl(createInvestigationDevCase(id, [longitude, latitude]))
).not.toThrow();
}
}
);
});
@@ -0,0 +1,284 @@
import type { AnswerUiBlock } from "@/features/agent";
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { InvestigationMapTarget } from "../api/investigation-report";
import {
createInvestigationDevSample,
createPresentationRecord,
createUiRecord,
normalizeDevCenter,
serializeInvestigationDevJsonl
} from "./investigation-dev-preview";
export const investigationDevCases = [
{
id: "combined",
label: "组合联动",
expected: "合成空间点、压力曲线、候选表格与数据一同展示;有调查地图目标时同时高亮。"
},
{
id: "map_targets",
label: "地图定位与高亮",
expected: "显示地图中心附近的合成点,并高亮所选调查已有的地图目标;无目标时仅显示合成点。"
},
{
id: "pressure_line",
label: "压力趋势对比",
expected: "打开两条合成压力曲线,核对时间轴、单位、图例和数据表。"
},
{
id: "impact_bar",
label: "影响用户统计",
expected: "打开合成用户数柱状图;统计仅供展示测试,不是实际停水户数。"
},
{
id: "impact_area",
label: "合成影响范围",
expected: "在当前地图中心附近显示一个合成面;不代表真实隔离边界。"
},
{
id: "complaint_heatmap",
label: "投诉热力分布",
expected: "显示三个带非负权重的合成投诉点,按热力模式渲染。"
},
{
id: "candidates_table",
label: "候选管段对比",
expected: "展示两条合成候选数据和对比表格,不查询或高亮虚拟管段。"
},
{
id: "valves_table",
label: "隔离阀门清单",
expected: "展示两条合成阀门数据和核查表格,不执行关阀或发布处置方案。"
},
{
id: "data_only",
label: "纯 JSONL 数据",
expected: "仅展示候选与影响数据;数据行中出现 action 字段也不会触发地图动作。"
},
{
id: "invalid_json",
label: "错误:JSON 语法",
expected: "第 2 行报语法错误,整批不执行;可点击执行验证拦截结果。"
},
{
id: "invalid_command",
label: "错误:不支持的动作",
expected: "第 1 行展示指令被拒绝,不执行任意命令。"
},
{
id: "invalid_chart",
label: "错误:图表数据不齐",
expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。"
}
] as const;
export type InvestigationDevCaseId = (typeof investigationDevCases)[number]["id"];
const jsonl = serializeInvestigationDevJsonl;
const presentation = createPresentationRecord;
// Invalid cases deliberately bypass the validated writer to exercise rejection.
const invalidJsonl = (records: unknown[]) =>
records.map((record) => JSON.stringify(record)).join("\n");
const data = (record_type: string, fields: Record<string, unknown>) => ({
record_type,
origin: "synthetic",
source_evidence_refs: [],
...fields
});
function table(id: string, title: string, columns: string[], rows: string[][]): unknown {
const block: AnswerUiBlock = {
version: "answer-ui@1",
block_id: `dev-case-${id}`,
fallback_text: `${title},仅供本地展示测试。`,
spec: {
root: "table",
elements: {
table: { type: "Table", props: { title, columns, rows }, children: [] }
}
}
};
return createUiRecord(block);
}
/** Fixtures use current map coordinates and existing task targets, never invented query IDs. */
export function createInvestigationDevCase(
caseId: InvestigationDevCaseId,
center: [number, number],
target?: InvestigationMapTarget
): string {
const [longitude, latitude] = normalizeDevCenter(center);
if (caseId === "combined") return createInvestigationDevSample([longitude, latitude], target);
const point = (dx = 0, dy = 0): [number, number] => [
Math.max(-180, Math.min(180, longitude + dx)),
latitude + dy
];
const overlay = (
mode: "geometry" | "heatmap",
title: string,
features: Extract<
InvestigationPresentationCommand,
{ action: "set_overlay" }
>["data"]["features"]
) =>
presentation({
action: "set_overlay",
overlay_id: `dev-case-${caseId}`,
title,
mode,
evidence_id: "dev:synthetic",
data: { type: "FeatureCollection", features }
});
switch (caseId) {
case "map_targets":
return jsonl([
...(target
? [
presentation({
action: "highlight_features",
layer: target.source_id,
feature_ids: [target.feature_id],
effect: "pulse"
})
]
: []),
overlay("geometry", "本地合成定位点", [
{
type: "Feature",
properties: { origin: "synthetic" },
geometry: { type: "Point", coordinates: point() }
}
])
]);
case "pressure_line":
return jsonl([
presentation({
action: "open_chart",
chart_id: "dev-case-pressure-line",
evidence_refs: ["dev:synthetic"],
chart: {
title: "本地合成压力对比",
chart_type: "line",
x_data: ["08:00", "08:15", "08:30", "08:45"],
series: [
{ name: "异常日压力水头(m)", data: [32, 18, 22, 30] },
{ name: "基线压力水头(m)", data: [33, 32, 32, 33] }
]
}
})
]);
case "impact_bar":
return jsonl([
presentation({
action: "open_chart",
chart_id: "dev-case-impact-bar",
evidence_refs: ["dev:synthetic"],
chart: {
title: "本地合成影响用户统计",
chart_type: "bar",
x_data: ["合成片区 A", "合成片区 B", "合成片区 C"],
series: [{ name: "合成用户数(户)", data: [120, 80, 45] }]
}
})
]);
case "impact_area":
return jsonl([
overlay("geometry", "本地合成影响范围", [
{
type: "Feature",
properties: { origin: "synthetic", purpose: "display_test_only" },
geometry: {
type: "Polygon",
coordinates: [
[
point(-0.002, -0.0015),
point(0.002, -0.0015),
point(0.002, 0.0015),
point(-0.002, 0.0015),
point(-0.002, -0.0015)
]
]
}
}
])
]);
case "complaint_heatmap":
return jsonl([
overlay(
"heatmap",
"本地合成投诉热力",
[-0.0015, 0, 0.0015].map((offset, index) => ({
type: "Feature",
properties: { origin: "synthetic", weight: [1, 3, 2][index] },
geometry: { type: "Point", coordinates: point(offset, offset / 2) }
}))
)
]);
case "candidates_table":
return jsonl([
table(
"candidates",
"合成候选管段对比",
["候选", "优先级", "核查依据"],
[
["DEMO-PIPE-01", "1", "合成压力降幅较大,待核查"],
["DEMO-PIPE-02", "2", "合成相关性较弱,待排除"]
]
),
data("candidate", { element_id: "DEMO-PIPE-01", rank: 1, status: "unverified" }),
data("candidate", { element_id: "DEMO-PIPE-02", rank: 2, status: "unverified" })
]);
case "valves_table":
return jsonl([
table(
"valves",
"合成隔离阀门清单",
["阀门", "操作建议", "核查要求"],
[
["DEMO-VALVE-01", "待评估", "位置与可操作性未核实"],
["DEMO-VALVE-02", "待评估", "连通性与影响范围未核实"]
]
),
data("valve", { element_id: "DEMO-VALVE-01", status: "unverified", executed: false }),
data("valve", { element_id: "DEMO-VALVE-02", status: "unverified", executed: false })
]);
case "data_only":
return jsonl([
data("candidate", {
element_id: "DEMO-PIPE-03",
action: "clear_highlights",
status: "data_only"
}),
data("impact", {
item: "合成片区 A",
estimated_users: 120,
unit: "户",
status: "example_only"
})
]);
case "invalid_json":
return `${jsonl([presentation({ action: "clear_highlights" })])}\n{"record_type":"candidate",`;
case "invalid_command":
return invalidJsonl([
{ record_type: "presentation", command: { action: "unsupported_test_action" } }
]);
case "invalid_chart":
return invalidJsonl([
{
record_type: "presentation",
command: {
action: "open_chart",
chart_id: "dev-invalid-chart",
evidence_refs: ["dev:synthetic"],
chart: {
title: "错误案例:数据长度不齐",
chart_type: "line",
x_data: ["08:00", "08:15"],
series: [{ name: "合成压力水头(m)", data: [32] }]
}
}
}
]);
}
}
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
createInvestigationDevSample,
investigationReportUiSummary,
parseInvestigationDevJsonl
} from "./investigation-dev-preview";
import type { OperationalTask } from "../api/operational-task-client";
describe("investigation Dev JSONL", () => {
it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => {
const result = parseInvestigationDevJsonl(
createInvestigationDevSample([121.48, 31.1], { source_id: "scada", feature_id: "P1" })
);
expect(result.commands.map((item) => item.action)).toEqual([
"highlight_features",
"set_overlay",
"open_chart"
]);
expect(result.blocks).toHaveLength(1);
expect(result.records).toEqual([
expect.objectContaining({
record_type: "candidate",
origin: "synthetic",
status: "example_only"
})
]);
});
it("never turns candidate data or an embedded command into an action", () => {
const result = parseInvestigationDevJsonl(
JSON.stringify({
record_type: "candidate",
origin: "system_data",
source_evidence_refs: ["saved-evidence"],
action: "highlight_features",
command: { action: "clear_highlights" },
element_id: "P1"
})
);
expect(result.commands).toEqual([]);
expect(result.records).toHaveLength(1);
});
it.each([
{ action: "clear_highlights" },
{ action: "execute", command: "rm files" },
{
action: "highlight_features",
layer: "pipes",
feature_ids: ["P1"],
effect: "pulse",
project_id: "other-project"
},
{ record_type: "presentation", run_id: "foreign-run", command: { action: "clear_highlights" } },
{
action: "open_chart",
chart_id: "bad-chart",
evidence_refs: ["e"],
chart: {
title: "bad",
chart_type: "line",
x_data: ["08:00", "08:15"],
series: [{ name: "pressure", data: [1] }]
}
}
])(
"rejects unsupported, scope-changing or inconsistent commands before execution: %j",
(value) => {
expect(() => parseInvestigationDevJsonl(JSON.stringify(value))).toThrow("第 1 行");
}
);
it("reports the physical line and returns no partially accepted batch", () => {
expect(() =>
parseInvestigationDevJsonl(
'\ufeff{"record_type":"presentation","command":{"action":"clear_highlights"}}\n\ninvalid'
)
).toThrow("第 3 行");
});
it("rejects malformed UI, repeated blocks and oversized batches", () => {
expect(() =>
parseInvestigationDevJsonl(
JSON.stringify({ record_type: "ui_block", block: { version: "answer-ui@1" } })
)
).toThrow("界面块不合法");
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
expect(() =>
parseInvestigationDevJsonl(
[block, block]
.map((item) => JSON.stringify({ record_type: "ui_block", block: item }))
.join("\n")
)
).toThrow("block_id 重复");
expect(() =>
parseInvestigationDevJsonl(Array(41).fill('{"action":"clear_highlights"}').join("\n"))
).toThrow("40 行");
expect(() => parseInvestigationDevJsonl("中".repeat(70_000))).toThrow("200 KB");
});
it("rejects bare UI blocks instead of guessing a record type", () => {
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
expect(() => parseInvestigationDevJsonl(JSON.stringify(block))).toThrow("第 1 行");
});
it("counts only registered valid report components", () => {
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
const task = {
latest_report: {
report: {
ui_blocks: [
{ block, evidence_refs: ["e"] },
{ block: { ...block, version: "future-ui" }, evidence_refs: ["e"] }
]
}
}
} as unknown as OperationalTask;
expect(investigationReportUiSummary(task)).toEqual({
blocks: 1,
charts: 0,
tables: 1,
maps: 0,
invalid: 1
});
});
});
describe("migrated example data", () => {
it("validates the portable fixture against all ten current commands and explicit UI phases", async () => {
const { default: input } =
await import("../../../../tests/fixtures/unified-presentation.jsonl?raw");
const { PRESENTATION_ACTIONS } = await import("@/features/agent");
const preview = parseInvestigationDevJsonl(input);
expect(preview.commands.map((command) => command.action).sort()).toEqual(
[...PRESENTATION_ACTIONS].sort()
);
expect(preview.blocks).toHaveLength(1);
expect(preview.blocks[0].phase).toBe("final");
expect(preview.records).toEqual([
expect.objectContaining({ origin: "synthetic", element_id: "DEMO-PIPE-01" })
]);
});
it("rejects invalid generated examples instead of exporting a partial or legacy batch", async () => {
const { serializeInvestigationDevJsonl } = await import("./investigation-dev-preview");
expect(() => serializeInvestigationDevJsonl([{ action: "clear_highlights" }])).toThrow(
"第 1 行"
);
expect(() =>
serializeInvestigationDevJsonl([
{ record_type: "presentation", command: { action: "clear_highlights" } },
{
record_type: "presentation",
command: { action: "zoom_to_map", center: [181, 31], zoom: 14 }
}
])
).toThrow("第 2 行");
});
});
@@ -0,0 +1,246 @@
import { z } from "zod";
import {
parseAnswerUiBlock,
presentationCommandSchema,
type AnswerUiBlock
} from "@/features/agent";
import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { InvestigationMapTarget } from "../api/investigation-report";
import type { OperationalTask } from "../api/operational-task-client";
export type InvestigationDevPreview = {
commands: InvestigationPresentationCommand[];
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
};
export type InvestigationPresentationActivity = {
id: number;
action: InvestigationPresentationCommand["action"];
source: "live" | "preview" | "chat";
status: "running" | "completed" | "error";
message?: string;
};
export const investigationActionLabels: Record<InvestigationPresentationCommand["action"], string> =
{
locate_features: "地图定位",
zoom_to_map: "调整视角",
render_scada_analysis: "SCADA 分析",
clear_scada_analysis: "清除 SCADA 分析",
highlight_features: "地图高亮",
clear_highlights: "清除高亮",
open_chart: "打开图表",
close_chart: "关闭图表",
set_overlay: "显示覆盖层",
clear_overlay: "清除覆盖层"
};
const references = z.array(z.string().trim().min(1).max(128)).max(100);
const command = presentationCommandSchema;
const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict();
const uiRecord = z
.object({
record_type: z.literal("ui_block"),
block: z.unknown(),
evidence_refs: references.optional()
})
.strict();
const dataRecord = z
.object({
record_type: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
origin: z.enum(["system_data", "synthetic", "derived", "human_input"]),
source_evidence_refs: references
})
.passthrough();
/** Only explicit presentation records execute. Skill data records remain data. */
export function parseInvestigationDevJsonl(input: string): InvestigationDevPreview {
if (new TextEncoder().encode(input).byteLength > 200_000)
throw new Error("JSONL 超过 200 KB,请缩小数据范围。");
const lines = input
.replace(/^\uFEFF/, "")
.split(/\r?\n/)
.map((text, index) => ({ text: text.trim(), line: index + 1 }))
.filter((item) => item.text);
if (!lines.length) throw new Error("请先载入样例、当前报告,或粘贴 JSONL。");
if (lines.length > 40) throw new Error("一次最多校验 40 行,请分批预览。");
const result: InvestigationDevPreview = { commands: [], blocks: [], records: [] };
const blockIds = new Set<string>();
for (const { text, line } of lines) {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(`第 ${line} 行不是合法 JSON。`);
}
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`第 ${line} 行必须是 JSON 对象。`);
const record = value as Record<string, unknown>;
if (record.record_type === "presentation") {
const parsed = presentationRecord.safeParse(value);
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法,请检查动作、参数和范围。`);
const data = parsed.data;
result.commands.push(data.command);
} else if (record.record_type === "ui_block") {
const wrapped = uiRecord.safeParse(value);
const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null);
if (!block || blockIds.has(block.block_id))
throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`);
blockIds.add(block.block_id);
result.blocks.push(block);
if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。");
} else {
const parsed = dataRecord.safeParse(value);
if (!parsed.success)
throw new Error(`第 ${line} 行不是已支持的展示指令、界面块或带来源的调查数据。`);
result.records.push(parsed.data);
}
}
return result;
}
/** Explicit records are the only export format; this never interprets legacy inputs. */
export function createPresentationRecord(command: InvestigationPresentationCommand) {
return presentationRecord.parse({ record_type: "presentation", command });
}
export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) {
const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" });
if (!normalized) throw new Error("界面块未通过当前组件协议校验。");
return uiRecord.parse({
record_type: "ui_block",
block: normalized,
evidence_refs: evidenceRefs
});
}
export function serializeInvestigationDevJsonl(records: unknown[]) {
const input = records.map((record) => JSON.stringify(record)).join("\n");
parseInvestigationDevJsonl(input);
return input;
}
export function investigationReportJsonl(task: OperationalTask) {
return (task.latest_report?.report.ui_blocks ?? [])
.map((item) =>
JSON.stringify({
record_type: "ui_block",
block: item.block,
evidence_refs: item.evidence_refs
})
)
.join("\n");
}
export function investigationReportUiSummary(task: OperationalTask | null) {
const counts = { blocks: 0, charts: 0, tables: 0, maps: 0, invalid: 0 };
for (const item of task?.latest_report?.report.ui_blocks ?? []) {
const block = parseAnswerUiBlock(item.block);
if (!block) {
counts.invalid += 1;
continue;
}
counts.blocks += 1;
for (const node of Object.values(block.spec.elements)) {
if (node.type === "Chart") counts.charts += 1;
if (node.type === "Table") counts.tables += 1;
if (node.type === "MapLocation") counts.maps += 1;
}
}
return counts;
}
export function normalizeDevCenter([longitude, latitude]: [number, number]): [number, number] {
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("地图中心坐标必须为有限数值。");
return [((((longitude + 180) % 360) + 360) % 360) - 180, Math.max(-89.99, Math.min(89.99, latitude))];
}
export function createInvestigationDevSample(
center: [number, number],
target?: InvestigationMapTarget
): string {
center = normalizeDevCenter(center);
const chart = {
title: "本地合成压力趋势",
chart_type: "line" as const,
x_data: ["08:00", "08:15", "08:30"],
series: [{ name: "压力水头(m)", data: [32, 28, 30] }]
};
const block: AnswerUiBlock = {
version: "answer-ui@1",
block_id: "dev-investigation-summary",
fallback_text: "仅供展示测试的合成候选对比,不是调查成果。",
spec: {
root: "summary",
elements: {
summary: {
type: "Stack",
props: { columns: "one", gap: "compact" },
children: ["notice", "candidates"]
},
notice: {
type: "Text",
props: { text: "本地合成样例,不代表真实故障、阀门或现场结果。", tone: "warning" },
children: []
},
candidates: {
type: "Table",
props: {
title: "合成候选对比",
columns: ["候选", "排序", "说明"],
rows: [
["DEMO-PIPE-01", "1", "仅测试表格展示"],
["DEMO-PIPE-02", "2", "未执行定位分析"]
]
},
children: []
}
}
}
};
const records: unknown[] = [];
if (target)
records.push(
createPresentationRecord({
action: "highlight_features",
layer: target.source_id,
feature_ids: [target.feature_id],
effect: "pulse"
})
);
records.push(
createPresentationRecord({
action: "set_overlay",
overlay_id: "dev-synthetic-area",
title: "本地合成空间样例",
mode: "geometry",
evidence_id: "dev:synthetic",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { origin: "synthetic" },
geometry: { type: "Point", coordinates: center }
}
]
}
}),
createPresentationRecord({
action: "open_chart",
chart_id: "dev-synthetic-pressure",
chart,
evidence_refs: ["dev:synthetic"]
}),
createUiRecord(block),
{
record_type: "candidate",
origin: "synthetic",
source_evidence_refs: [],
element_id: "DEMO-PIPE-01",
rank: 1,
status: "example_only"
}
);
return serializeInvestigationDevJsonl(records);
}
@@ -81,9 +81,10 @@ describe("investigation diagram presentation", () => {
]
});
expect(styled.startsWith(source)).toBe(true);
expect(styled).toContain("class B tjEvidence;");
expect(styled).toContain("class B tjUnknown;");
expect(styled).not.toContain("class missing ");
expect(styled).not.toMatch(/completed|running|succeeded/);
expect(styled).not.toContain("class B tjCompleted;");
expect(styled).not.toContain("class B tjRunning;");
});
it("uses blue only for an explicitly running current diagram, and neutralizes historical running classes", () => {
const diagram = { title: "当前核查", mermaid: "flowchart TD\nA[查询]:::tjAgent --> B[判断]:::tjDecision",
@@ -92,6 +93,22 @@ describe("investigation diagram presentation", () => {
expect(styleInvestigationDiagram(diagram, true)).not.toContain("class A tjEvidence;");
const historical = styleInvestigationDiagram(diagram, false);
expect(historical).not.toContain("class A tjRunning;");
expect(historical).toContain("class A tjRecordedRunning;");
expect(historical).not.toContain("#4263C7");
});
it.each([
["pending", "tjPending"], ["running", "tjRunning"],
["completed", "tjCompleted"], ["waiting", "tjWaiting"],
["waiting_human", "tjWaitingHuman"], ["failed", "tjFailed"],
["skipped", "tjSkipped"], ["cancelled", "tjCancelled"]
] as const)("uses the explicit %s state even when evidence or actions are linked", (status, className) => {
const source = "flowchart LR\nA[核查] --> B[下一步]";
const styled = styleInvestigationDiagram({ title: "核查", mermaid: source,
node_refs: [{ node_id: "A", label: "核查", status, evidence_refs: ["e1"], action_refs: ["a1"] }] }, true);
expect(styled.startsWith(source)).toBe(true);
expect(styled).toContain(`class A ${className};`);
expect(styled).not.toContain("class A tjEvidence;");
expect(styled).not.toContain("class A tjAction;");
expect(styled).not.toContain("class B tjCompleted;");
});
});
@@ -11,7 +11,7 @@ export const investigationDiagramConfig: MermaidConfig = {
fontFamily: INVESTIGATION_DIAGRAM_FONT,
themeVariables: {
fontFamily: INVESTIGATION_DIAGRAM_FONT,
fontSize: "15px",
fontSize: "18px",
background: "#FFFFFF",
primaryColor: "#FFFFFF",
primaryTextColor: "#26334D",
@@ -28,6 +28,7 @@ export const investigationDiagramConfig: MermaidConfig = {
clusterBorder: "#CCD6E5"
},
flowchart: {
useMaxWidth: false,
curve: "stepAfter",
nodeSpacing: 56,
rankSpacing: 64,
@@ -42,10 +43,10 @@ export const investigationDiagramConfig: MermaidConfig = {
.nodeLabel { font-weight: 500; }
.nodeLabel p { text-wrap: balance; line-height: 1.6; }
.edgePath path, .flowchart-link { stroke-width: 1.35px; }
.edgeLabel { color: #536784; font-size: 12px; }
.edgeLabel { color: #536784; font-size: 15px; }
.edgeLabel p { padding: 3px 7px; border-radius: 4px; line-height: 1.5; }
.cluster rect { rx: 8px; ry: 8px; stroke-width: 1px; }
.cluster-label { font-weight: 600; font-size: 13px; }
.cluster-label { font-weight: 600; font-size: 16px; }
.cluster-label .nodeLabel { background: #F6F8FC; padding: 0 4px; }
`
};
@@ -108,15 +109,60 @@ export const investigationDiagramPlugins = {
}
};
const roleStyles = [
"classDef tjAgent fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
"classDef tjHuman fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.4px;",
"classDef tjDecision fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.5px;",
"classDef tjEvidence fill:#EDF7F4,stroke:#6B9E8B,color:#2D5E4E,stroke-width:1.6px;",
"classDef tjAction fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.6px;"
];
export const investigationDiagramStates = {
pending: { className: "tjPending", label: "尚未开始", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "0" },
running: { className: "tjRunning", label: "正在执行", fill: "#E8F0FF", stroke: "#4263C7", color: "#284574", width: 2.5, dash: "0" },
completed: { className: "tjCompleted", label: "已完成", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" },
waiting: { className: "tjWaiting", label: "等待依赖 / 外部结果", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" },
waiting_human: { className: "tjWaitingHuman", label: "等待人工处理", fill: "#FFF6E8", stroke: "#C4A168", color: "#735018", width: 2, dash: "0" },
failed: { className: "tjFailed", label: "执行失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" },
skipped: { className: "tjSkipped", label: "已跳过", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" },
cancelled: { className: "tjCancelled", label: "已取消", fill: "#F1F5F9", stroke: "#94A3B8", color: "#64748B", width: 1.5, dash: "2 3" },
recorded_running: { className: "tjRecordedRunning", label: "记录时执行中", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "6 3" },
unknown: { className: "tjUnknown", label: "未标注状态", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" }
} as const;
/** Style supplied relationships; never create nodes from a dangling display reference. */
export function investigationNodeState(
node: InvestigationDiagram["node_refs"][number],
running = false
): keyof typeof investigationDiagramStates {
if (node.status === "running" && !running) return "recorded_running";
return node.status ?? "unknown";
}
function nodeStyle(state: typeof investigationDiagramStates[keyof typeof investigationDiagramStates]) {
return `fill:${state.fill},stroke:${state.stroke},color:${state.color},stroke-width:${state.width}px,stroke-dasharray:${state.dash}`;
}
export const investigationDiagramNodeId = (node: Element | null | undefined) =>
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
/** Update state-only changes in place, preserving the reader's pan and zoom. */
export function paintInvestigationDiagramStates(root: Element, diagram: InvestigationDiagram, running: boolean) {
const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node]));
const classes = Object.values(investigationDiagramStates).map((state) => state.className);
root.querySelectorAll<SVGGElement>("svg g.node").forEach((node) => {
const ref = refs.get(investigationDiagramNodeId(node) ?? "");
const state = investigationDiagramStates[ref ? investigationNodeState(ref, running) : "unknown"];
node.classList.remove(...classes);
node.classList.add(state.className);
node.setAttribute("aria-label", `${ref?.label ?? node.textContent?.trim() ?? "节点"}:${state.label}`);
for (const shape of Array.from(node.children)) {
if (!shape.matches("rect, polygon, path, circle, ellipse")) continue;
const style = (shape as SVGElement).style;
style.setProperty("fill", state.fill, "important");
style.setProperty("stroke", state.stroke, "important");
style.setProperty("stroke-width", `${state.width}px`, "important");
style.setProperty("stroke-dasharray", state.dash, "important");
}
node.querySelectorAll<SVGElement | HTMLElement>(".nodeLabel, .nodeLabel p").forEach((label) => {
label.style.setProperty("color", state.color, "important");
label.style.setProperty("fill", state.color, "important");
});
});
}
/** Color explicit recorded states; relationships and evidence never imply completion. */
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) {
const assignments = diagram.node_refs.flatMap((node) => {
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -124,14 +170,15 @@ export function styleInvestigationDiagram(diagram: InvestigationDiagram, running
diagram.mermaid
);
if (!inSource) return [];
if (running && node.status === "running") return [`class ${node.node_id} tjRunning;`];
return node.action_refs.length
? [`class ${node.node_id} tjAction;`]
: node.evidence_refs.length
? [`class ${node.node_id} tjEvidence;`]
: [];
const state = investigationDiagramStates[investigationNodeState(node, running)];
return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`];
});
return [diagram.mermaid, ...roleStyles,
running ? "classDef tjRunning fill:#E8F0FF,stroke:#4263C7,color:#284574,stroke-width:2.5px;" : "classDef tjRunning fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
...assignments].join("\n");
// Keep role/reference classes neutral. Their presence conveys association,
// while only node_refs.status supplies an execution fact.
const roles = ["default", "tjAgent", "tjHuman", "tjDecision", "tjEvidence", "tjAction"]
.map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.unknown)};`);
const states = Object.entries(investigationDiagramStates).map(([key, state]) =>
`classDef ${state.className} ${nodeStyle(key === "running" && !running ? investigationDiagramStates.recorded_running : state)};`
);
return [diagram.mermaid, ...roles, ...states, ...assignments].join("\n");
}
@@ -0,0 +1,204 @@
import { describe, expect, it, vi } from "vitest";
import type { InvestigationPresentationHistoryEvent } from "../api/investigation-presentation";
import type { InvestigationMapPresentation } from "../map/investigation-map-presentation";
import { presentationHistoryPorts } from "../map/presentation-history-ports";
import { hydrateInvestigationPresentationArchive } from "./investigation-presentation-archive";
import { PresentationViewHistory } from "./presentation-view-history";
const task = {
task_id: "50000000-0000-4000-8000-000000000001",
title: "已保存调查",
execution_generation: 2,
evidence: [],
latest_report: null
};
const chart = (title: string) => ({
action: "open_chart",
chart_id: "pressure",
evidence_refs: ["reading"],
chart: { title, chart_type: "line", x_data: ["08:00"], series: [{ name: "压力", data: [20] }] }
});
const event = (
event_seq: number,
command: unknown,
execution_generation = 2
): InvestigationPresentationHistoryEvent => ({
task_id: task.task_id,
event_seq,
run_id: "55000000-0000-4000-8000-000000000001",
execution_generation,
occurred_at: "2026-10-27T08:00:00Z",
command
});
function setup() {
const history = new PresentationViewHistory();
const scene = {
highlight: vi.fn(),
clearHighlights: vi.fn(),
setOverlay: vi.fn(),
clearOverlay: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearScadaAnalysis: vi.fn()
};
const controller = {
queryPresentationFeatures: vi.fn(async () => ({
type: "FeatureCollection" as const,
features: [
{
type: "Feature" as const,
properties: { device_id: "P1" },
geometry: { type: "Point" as const, coordinates: [121.4, 31.1] }
}
]
}))
};
const createPorts = (staging: PresentationViewHistory) =>
presentationHistoryPorts({
controller,
scene: scene as unknown as InvestigationMapPresentation,
map: () => null,
padding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
update: (updater) => staging.update(updater(staging.current)),
rendering: () => false,
cameraAllowed: () => true,
waitForChart: async () => undefined,
frame: async () => undefined
});
const load = (
events: InvestigationPresentationHistoryEvent[],
signal = new AbortController().signal
) => hydrateInvestigationPresentationArchive({ events, task, history, createPorts, signal });
return { history, scene, controller, load, createPorts };
}
describe("saved investigation presentation", () => {
it("hydrates sequential snapshots, applies cleanup without history rows and deduplicates task reselection", async () => {
const test = setup();
const events = [
event(1, {
action: "highlight_features",
layer: "scada",
feature_ids: ["P1"],
effect: "pulse"
}),
event(2, chart("原始压力")),
event(3, { action: "close_chart", chart_id: "pressure" }),
event(4, { action: "clear_highlights" }),
event(5, chart("最终压力"))
];
const current = await test.load(events);
expect(current.charts[0].chart.title).toBe("最终压力");
expect(current.highlights.features).toHaveLength(0);
expect(test.history.entries).toHaveLength(4);
expect(test.history.entries[1].snapshot.charts[0].chart.title).toBe("原始压力");
expect(test.history.entries[1].snapshot.highlights.features).toHaveLength(1);
const again = await test.load(events);
expect(again.charts[0].chart.title).toBe("最终压力");
expect(test.history.entries).toHaveLength(4);
expect(test.controller.queryPresentationFeatures).toHaveBeenCalledTimes(1);
expect(Object.values(test.scene).every((fn) => fn.mock.calls.length === 0)).toBe(true);
});
it("retains prior generations as history without leaking their charts into the current generation", async () => {
const test = setup();
const next = await test.load([event(1, chart("上一轮"), 1), event(2, chart("本轮"))]);
expect(next.charts.map((item) => item.chart.title)).toEqual(["本轮"]);
expect(test.history.entries[0].taskTitle).toContain("第 1 轮");
const previousOnly = await test.load([event(1, chart("上一轮"), 1)]);
expect(previousOnly.charts).toEqual([]);
});
it("rejects unregistered commands individually and refuses another task's records", async () => {
const test = setup();
const current = await test.load([
event(1, { action: "execute", command: "unsafe" }),
event(2, chart("有效曲线"))
]);
expect(test.history.entries.map((item) => item.status)).toEqual(["error", "completed"]);
expect(current.charts[0].chart.title).toBe("有效曲线");
await expect(
test.load([
{ ...event(3, chart("错误任务")), task_id: "50000000-0000-4000-8000-000000000002" }
])
).rejects.toThrow("不属于所选调查");
});
it("does not append late results after a task switch aborts a feature lookup", async () => {
const test = setup();
let done!: () => void;
const pending = new Promise<void>((resolve) => {
done = resolve;
});
test.controller.queryPresentationFeatures.mockImplementationOnce(async () => {
await pending;
return { type: "FeatureCollection", features: [] };
});
const controller = new AbortController();
const loading = test.load(
[event(1, { action: "locate_features", layer: "scada", feature_ids: ["P1"] })],
controller.signal
);
controller.abort();
done();
await expect(loading).rejects.toThrow();
expect(test.history.entries).toHaveLength(0);
});
});
it("loads saved report blocks without display events and does not duplicate them on reselection", async () => {
const test = setup();
const reportTask = {
...task,
latest_report: {
revision: 3,
execution_generation: 2,
source_session_id: "saved",
published_by: "agent",
published_at: "2026-10-27T08:00:00Z",
report: {
title: "保存的调查报告",
current_conclusion: "待现场核查",
conclusion_boundary: "合成测试",
evidence_refs: [],
recommendations: [],
pending_items: [],
ui_blocks: [
{
evidence_refs: [],
block: {
version: "answer-ui@1" as const,
block_id: "saved-valves",
phase: "final" as const,
fallback_text: "阀门数据",
spec: {
root: "note",
elements: {
note: {
type: "Text" as const,
props: { text: "已保存阀门核查结果", tone: "neutral" as const },
children: []
}
}
}
}
}
]
}
}
};
const first = await hydrateInvestigationPresentationArchive({
events: [],
task: reportTask,
history: test.history,
createPorts: () => ({}) as never,
signal: new AbortController().signal
});
expect(first.blocks[0].block_id).toBe("saved-valves");
const second = await hydrateInvestigationPresentationArchive({
events: [],
task: reportTask,
history: test.history,
createPorts: () => ({}) as never,
signal: new AbortController().signal
});
expect(second.blocks).toBe(first.blocks);
expect(test.history.entries).toHaveLength(1);
expect(test.history.entries[0].label).toContain("报告第 3 版");
});
@@ -0,0 +1,102 @@
import { parseAnswerUiBlock } from "@/features/agent";
import type { OperationalTask } from "../api/operational-task-client";
import {
investigationPresentationEventSchema,
presentationEventKey,
type InvestigationPresentationHistoryEvent
} from "../api/investigation-presentation";
import {
executePresentationCommand,
type PresentationPorts
} from "../map/presentation-command-executor";
import { presentationCommandLabel } from "./presentation-command-label";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
/** Hydrate saved display data offscreen. No business actions, SSE publication or map mutations. */
export async function hydrateInvestigationPresentationArchive({
events,
task,
history,
createPorts,
signal
}: {
events: InvestigationPresentationHistoryEvent[];
task: Pick<
OperationalTask,
"task_id" | "title" | "execution_generation" | "evidence" | "latest_report"
>;
history: PresentationViewHistory;
createPorts: (staging: PresentationViewHistory) => PresentationPorts;
signal: AbortSignal;
}) {
const staging = new PresentationViewHistory();
const ports = createPorts(staging);
let generation: number | null = null;
for (const event of events) {
signal.throwIfAborted();
if (event.task_id !== task.task_id) throw new Error("历史展示不属于所选调查");
if (event.execution_generation !== generation) {
generation = event.execution_generation;
staging.update({
...emptyPresentationView(),
evidence: task.evidence.map(({ evidence_id, summary }) => ({ evidence_id, summary }))
});
}
const key = presentationEventKey(task.task_id, event.event_seq);
const cached = history.entries.find((entry) => entry.key === key);
if (cached) {
staging.update(cached.snapshot);
continue;
}
const parsed = investigationPresentationEventSchema.safeParse({
task_id: event.task_id,
run_id: event.run_id,
execution_generation: event.execution_generation,
command: event.command
});
let message: string | undefined;
try {
if (!parsed.success) throw new Error("已保存的展示指令不符合当前格式,未执行。");
await executePresentationCommand(parsed.data.command, signal, ports);
} catch (cause) {
signal.throwIfAborted();
message = cause instanceof Error ? cause.message : "历史展示暂不可用";
}
signal.throwIfAborted();
if (parsed.success && parsed.data.command.action.startsWith("clear_")) continue;
const id = history.begin(
parsed.success
? presentationCommandLabel(parsed.data.command, staging.current)
: "历史展示参数无效",
"live",
`${task.title} · 第 ${generation} 轮`,
{ key, taskId: task.task_id, createdAt: Date.parse(event.occurred_at) }
);
history.finish(id, message ? "error" : "completed", message, staging.current);
}
let current =
generation === task.execution_generation ? staging.current : emptyPresentationView();
const report = task.latest_report;
if (report?.execution_generation === task.execution_generation) {
const key = `task:${task.task_id}:report:${report.revision}`;
const cached = history.entries.find((entry) => entry.key === key);
const blocks =
cached?.snapshot.blocks ??
(report.report.ui_blocks ?? []).flatMap((item) => {
const block = parseAnswerUiBlock(item.block);
return block ? [block] : [];
});
if (blocks.length) {
current = { ...current, blocks };
if (!cached) {
const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, {
key,
taskId: task.task_id,
createdAt: Date.parse(report.published_at)
});
history.finish(id, "completed", undefined, current);
}
}
}
return current;
}
@@ -0,0 +1,11 @@
const ease = [0.22, 1, 0.36, 1] as const;
export const presentationTransition = (immediate: boolean) => ({
duration: immediate ? 0 : 0.18,
ease
});
export const presentationFade = (immediate: boolean) => ({
initial: immediate ? (false as const) : { opacity: 0, y: 6 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: immediate ? 0 : -4 },
transition: presentationTransition(immediate)
});
@@ -69,6 +69,7 @@ export function investigationStatus(task: OperationalTask): string {
if (task.active_run?.status === "running") return "调查中";
if (task.lifecycle_status === "failed") return "本轮执行失败";
if (task.lifecycle_status === "cancelled") return "本轮已取消";
if (task.questions?.some((question) => question.status === "pending" && question.execution_generation === task.execution_generation)) return "等待回答";
if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核";
if (
task.actions.some(
@@ -0,0 +1,20 @@
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
import { investigationActionLabels } from "./investigation-dev-preview";
import type { PresentationView } from "./presentation-view-history";
export function presentationCommandLabel(
command: InvestigationPresentationCommand,
view: PresentationView
) {
const detail =
command.action === "open_chart"
? command.chart.title
: command.action === "set_overlay"
? command.title
: command.action === "close_chart"
? view.charts.find((chart) => chart.chart_id === command.chart_id)?.chart.title
: "feature_ids" in command
? command.feature_ids.slice(0, 3).join("、")
: null;
return `${investigationActionLabels[command.action]}${detail ? ` · ${detail}` : ""}`;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
describe("presentation history", () => {
it("retains every step across clear and keeps a past view pinned while current advances", () => {
const history = new PresentationViewHistory();
for (let index = 0; index < 15; index++) {
history.update({
...history.current,
records: [{ record_type: "candidate", element_id: `pipe-${index}` }]
});
history.record(`step-${index}`, "live", "调查");
}
expect(history.entries).toHaveLength(15);
const first = history.entries[0];
expect(history.select(first.id)).toBe(true);
history.update(emptyPresentationView());
expect(history.visible.records[0].element_id).toBe("pipe-0");
expect(history.current.records).toEqual([]);
history.select(null);
expect(history.visible.records).toEqual([]);
expect(history.entries).toHaveLength(15);
history.select(first.id);
expect(history.visible.records[0].element_id).toBe("pipe-0");
});
it("merges only the matching latest result and restores a separate history option after cleanup", () => {
const history = new PresentationViewHistory();
expect(history.currentEntryId).toBeNull();
const first = history.record("曲线", "live", null);
expect(history.currentEntryId).toBe(first);
history.update({
...history.current,
camera: { center: [121, 31], zoom: 15, bearing: 0, pitch: 0 }
});
expect(history.currentEntryId).toBe(first);
const pending = history.begin("阀门列表", "live", null);
expect(history.currentEntryId).toBeNull();
history.update({ ...history.current, records: [{ element_id: "V1" }] });
history.finish(pending, "completed");
expect(history.currentEntryId).toBe(pending);
history.update(emptyPresentationView());
expect(history.currentEntryId).toBeNull();
expect(history.entries).toHaveLength(2);
expect(history.select(pending)).toBe(true);
expect(history.visible.records).toEqual([{ element_id: "V1" }]);
});
it("reserves action order, retains errors and cancellation, and prevents stale completions after project reset", () => {
const history = new PresentationViewHistory();
const pending = history.begin("query", "live", null);
expect(history.select(pending)).toBe(false);
const captured = { ...history.current, records: [{ element_id: "captured" }] };
history.update({ ...history.current, records: [{ element_id: "later action" }] });
history.finish(pending, "error", "not found", captured);
expect(history.select(pending)).toBe(true);
expect(history.visible.records).toEqual([{ element_id: "captured" }]);
expect(history.selected?.message).toBe("not found");
const cancelled = history.begin("cancel", "preview", null);
history.finish(cancelled, "cancelled");
expect(history.entries.map((item) => item.status)).toEqual(["error", "cancelled"]);
history.reset();
const fresh = history.begin("new project", "live", null);
history.finish(pending, "completed");
expect(fresh).not.toBe(pending);
expect(history.entries).toHaveLength(1);
expect(history.entries[0].status).toBe("running");
expect(history.selectedId).toBeNull();
expect(history.current.records).toEqual([]);
});
});
@@ -0,0 +1,141 @@
import type { FeatureCollection } from "geojson";
import type { AnswerUiBlock } from "@/features/agent";
import type {
InvestigationOverlay,
InvestigationPresentationCommand
} from "../api/investigation-presentation";
import type { ScadaAnalysisItem } from "../map/scada-analysis";
import type { InvestigationPresentationActivity } from "./investigation-dev-preview";
export type PresentationCamera = {
center: [number, number];
zoom: number;
bearing: number;
pitch: number;
};
export type PresentationView = {
charts: Array<Extract<InvestigationPresentationCommand, { action: "open_chart" }>>;
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
evidence: Array<{ evidence_id: string; summary: string }>;
highlights: FeatureCollection;
highlightLayers: string[];
overlay: InvestigationOverlay | null;
scada: { data: FeatureCollection; items: ScadaAnalysisItem[] } | null;
camera: PresentationCamera | null;
};
export type PresentationHistoryEntry = {
id: number;
key?: string;
taskId?: string;
label: string;
source: InvestigationPresentationActivity["source"];
taskTitle: string | null;
createdAt: number;
status: "running" | "completed" | "error" | "cancelled";
message?: string;
snapshot: PresentationView;
};
export type PresentationHistoryControls = {
currentEntryId: number | null;
entries: PresentationHistoryEntry[];
selectedId: number | null;
onSelect: (id: number | null) => void;
};
export const emptyPresentationView = (): PresentationView => ({
charts: [],
blocks: [],
records: [],
evidence: [],
highlights: { type: "FeatureCollection", features: [] },
highlightLayers: [],
overlay: null,
scada: null,
camera: null
});
/** Page-local immutable snapshots. Updating current never changes a pinned historical view. */
export class PresentationViewHistory {
current = emptyPresentationView();
entries: PresentationHistoryEntry[] = [];
selectedId: number | null = null;
private sequence = 0;
get currentEntryId() {
if (this.entries.at(-1)?.status === "running") return null;
// Results share immutable values with their snapshot. Capturing a manually moved
// camera for navigation does not turn the latest result into an older result.
const keys = Object.keys(this.current) as Array<keyof PresentationView>;
return (
this.entries
.slice()
.reverse()
.find(
(entry) =>
entry.status === "completed" &&
keys.every((key) => key === "camera" || entry.snapshot[key] === this.current[key])
)?.id ?? null
);
}
get selected() {
return this.entries.find((entry) => entry.id === this.selectedId) ?? null;
}
get visible() {
return this.selected?.snapshot ?? this.current;
}
update(next: PresentationView) {
this.current = next;
}
begin(
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
) {
const id = ++this.sequence;
this.entries = [
...this.entries,
{
id,
label,
source,
taskTitle,
createdAt: Date.now(),
...metadata,
status: "running",
snapshot: this.current
}
];
return id;
}
finish(
id: number,
status: Exclude<PresentationHistoryEntry["status"], "running">,
message?: string,
snapshot = this.current
) {
this.entries = this.entries.map((entry) =>
entry.id === id ? { ...entry, status, message, snapshot } : entry
);
}
record(
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
) {
const id = this.begin(label, source, taskTitle, metadata);
this.finish(id, "completed");
return id;
}
select(id: number | null) {
if (id !== null && !this.entries.some((entry) => entry.id === id && entry.status !== "running"))
return false;
this.selectedId = id;
return true;
}
reset() {
this.current = emptyPresentationView();
this.entries = [];
this.selectedId = null;
}
}
@@ -0,0 +1,74 @@
import { useRef, useState, type ReactNode } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ExpandableReading, ReadingWorkspaceProvider } from "@/shared/ui/reading-workspace";
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
import { WorkbenchMainFrame } from "./workbench-main-frame";
vi.mock("./analysis-document-view", () => ({ AnalysisArtifactWorkspace: () => null }));
vi.mock("./artifact-peek-bar", () => ({ ArtifactPeekBar: () => null }));
vi.mock("./workbench-content-split", () => ({
WorkbenchContentSplit: ({ map, document }: { map: ReactNode; document: ReactNode }) => <>{map}{document}</>
}));
function Fixture({ split = true }: { split?: boolean }) {
const reading = useReadingWorkspace();
const composer = useRef<HTMLInputElement>(null);
const [value, setValue] = useState("");
return <>
<label>继续输入<input ref={composer} /></label>
<ExpandableReading title="压力核查报告">
<label htmlFor="site-record">现场记录</label>
<input id="site-record" value={value} onChange={(event) => setValue(event.currentTarget.value)} />
<button type="button" onClick={() => { reading?.close(); requestAnimationFrame(() => composer.current?.focus()); }}>继续提问</button>
</ExpandableReading>
<WorkbenchMainFrame mapContent={<button type="button">地图操作</button>} timelineContent={null}
timelineMode="compact" timelineBottom={0} activeArtifact={null} pendingArtifact={null} surfaceMode="map_only"
mapSplitAvailable={split} readingSplitAvailable={split} onOpenPendingArtifact={() => {}}
onDestroyPendingArtifact={() => {}} onSetArtifactView={() => {}} onCollapseArtifact={() => {}} onDestroyArtifact={() => {}} />
</>;
}
describe("modal report reading", () => {
it("traps focus above a shared overlay, preserves the live editor through map comparison, then returns focus", async () => {
const user = userEvent.setup();
const { container } = render(<ReadingWorkspaceProvider><Fixture /></ReadingWorkspaceProvider>);
const editor = screen.getByLabelText("现场记录");
await user.type(editor, "尚未发送的现场资料");
const trigger = screen.getByRole("button", { name: "展开查看" });
await user.click(trigger);
let dialog = screen.getByRole("dialog", { name: "压力核查报告" });
expect(container.querySelector(".report-reading-backdrop")).toBeNull();
expect(document.body.querySelector(".report-reading-backdrop")).toBeInTheDocument();
expect(within(dialog).getByLabelText("现场记录")).toBe(editor);
for (let index = 0; index < 7; index++) await user.tab();
expect(dialog).toContainElement(document.activeElement as HTMLElement);
await user.click(within(dialog).getByRole("button", { name: "地图对照" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(document.body.querySelector(".report-reading-backdrop")).not.toBeInTheDocument();
expect(within(screen.getByRole("region", { name: "展开报告工作区" })).getByLabelText("现场记录")).toBe(editor);
expect(editor).toHaveValue("尚未发送的现场资料");
await user.click(screen.getByRole("button", { name: "地图操作" }));
await user.click(screen.getByRole("button", { name: "专注阅读" }));
dialog = screen.getByRole("dialog", { name: "压力核查报告" });
expect(within(dialog).getByLabelText("现场记录")).toBe(editor);
await user.keyboard("{Escape}");
await waitFor(() => expect(trigger).toHaveFocus());
expect(editor).toHaveValue("尚未发送的现场资料");
expect(document.body.querySelector(".report-reading-backdrop")).not.toBeInTheDocument();
});
it("removes the overlay for map-only mode and respects a follow-up's composer focus", async () => {
const user = userEvent.setup();
render(<ReadingWorkspaceProvider><Fixture split={false} /></ReadingWorkspaceProvider>);
await user.click(screen.getByRole("button", { name: "展开查看" }));
await user.click(screen.getByRole("button", { name: "查看地图" }));
await waitFor(() => expect(document.body.querySelector(".report-reading-backdrop")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "地图操作" }));
await user.click(screen.getByRole("button", { name: "返回报告" }));
const dialog = screen.getByRole("dialog", { name: "压力核查报告" });
await user.click(within(dialog).getByRole("button", { name: "继续提问" }));
await waitFor(() => expect(screen.getByLabelText("继续输入")).toHaveFocus());
});
});
@@ -1,9 +1,10 @@
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
import { useReadingWorkspace, type ReadingDocument } from "@/shared/ui/reading-workspace-context";
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
import { ReadingWorkspaceContent } from "@/shared/ui/reading-workspace";
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
@@ -56,6 +57,8 @@ export function WorkbenchMainFrame({
onDestroyArtifact
}: WorkbenchMainFrameProps) {
const reading = useReadingWorkspace();
const lastReading = useRef<ReadingDocument | null>(null);
useEffect(() => { if (reading?.active) lastReading.current = reading.active; }, [reading?.active]);
const surfaceMode = reading?.active
? reading.view === "map_split" && !readingSplitAvailable
? "focus"
@@ -88,6 +91,8 @@ export function WorkbenchMainFrame({
// Map imagery fills the header backdrop; documents and controls reserve the header inset.
return (
<Dialog open={Boolean(reading?.active && surfaceMode === "focus")}
onOpenChange={(open) => { if (!open) reading?.close(); }}>
<div
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0"
data-testid="workbench-main-frame"
@@ -156,54 +161,10 @@ export function WorkbenchMainFrame({
) : null}
</AnimatePresence>
{reading?.active ? (
<section
aria-label="展开报告工作区"
className={cn(
"agent-report-workspace surface-dock absolute inset-x-0 bottom-0 top-14 z-30 flex min-h-0 min-w-0 flex-col",
reading.view === "map_only" && "hidden"
)}
>
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-3">
<div className="min-w-0 flex-1">
<p className="text-[11px] text-muted-foreground">报告与分析</p>
<h2 className="break-words text-sm font-semibold">{reading.active.title}</h2>
</div>
{readingSplitAvailable ? (
<Button
variant="ghost"
size="sm"
onClick={() =>
reading.setView(surfaceMode === "map_split" ? "focus" : "map_split")
}
>
{surfaceMode === "map_split" ? <Focus size={14} /> : <MapIcon size={14} />}
{surfaceMode === "map_split" ? "专注阅读" : "地图对照"}
</Button>
) : (
<Button variant="ghost" size="sm" onClick={() => reading.setView("map_only")}>
<MapIcon size={14} />
查看地图
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
aria-label="收起展开报告"
onClick={() => reading.close()}
>
<X size={16} />
</Button>
</header>
<div
data-reading-scroll
className="surface-reading min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-24 md:px-6 md:pb-6"
>
<div className="mx-auto max-w-5xl pt-4 md:pt-6">
<ReadingWorkspaceContent />
</div>
</div>
</section>
{reading?.active && surfaceMode !== "focus" ? (
<div className={cn("report-reading-split absolute z-30 min-h-0 min-w-0 overflow-hidden rounded-xl border border-border bg-card", reading.view === "map_only" && "hidden")}>
<ReportReadingPanel splitAvailable={readingSplitAvailable} surfaceMode={surfaceMode} />
</div>
) : null}
</>
}
@@ -305,6 +266,47 @@ export function WorkbenchMainFrame({
) : null}
</AnimatePresence>
</motion.div>
<DialogContent className="report-reading-dialog motion-reduce:animate-none"
overlayClassName="report-reading-backdrop motion-reduce:animate-none" data-agent-theme="chat"
aria-describedby={undefined} showCloseButton={false}
onOpenAutoFocus={(event) => { event.preventDefault(); reading?.active?.host.focus({ preventScroll: true }); }}
onCloseAutoFocus={(event) => {
event.preventDefault();
const focused = document.activeElement;
const previous = lastReading.current;
// A deliberate follow-up may already have focused the composer or another dialog.
if (!reading?.active && previous && (!focused || focused === document.body || previous.host.contains(focused))) previous.restoreFocus?.();
}}
onEscapeKeyDown={(event) => { event.preventDefault(); event.stopPropagation(); reading?.close(); }}>
<ReportReadingPanel splitAvailable={readingSplitAvailable} surfaceMode={surfaceMode} />
</DialogContent>
</div>
</Dialog>
);
}
function ReportReadingPanel({ splitAvailable, surfaceMode }: { splitAvailable: boolean; surfaceMode: WorkbenchSurfaceMode }) {
const reading = useReadingWorkspace();
const reduced = useReducedMotion();
if (!reading?.active) return null;
return <section aria-label="展开报告工作区" className="agent-report-workspace flex min-h-0 min-w-0 flex-col overflow-hidden bg-card">
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
<DialogTitle className="break-words text-base leading-6 tracking-normal">{reading.active.title}</DialogTitle>
</div>
{splitAvailable ? <Button variant="ghost" size="icon-sm" aria-label={surfaceMode === "map_split" ? "专注阅读" : "地图对照"} title={surfaceMode === "map_split" ? "专注阅读" : "地图对照"} onClick={() => reading.setView(surfaceMode === "map_split" ? "focus" : "map_split")}>
{surfaceMode === "map_split" ? <Focus size={14} aria-hidden /> : <MapIcon size={14} aria-hidden />}
</Button> : <Button variant="ghost" size="icon-sm" aria-label="查看地图" title="查看地图" onClick={() => reading.setView("map_only")}>
<MapIcon size={14} aria-hidden />
</Button>}
<Button variant="ghost" size="icon-sm" aria-label="收起展开报告" onClick={() => reading.close()}><X size={17} aria-hidden /></Button>
</header>
<div data-reading-scroll className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-6 md:px-6">
<motion.div className="mx-auto min-w-0 max-w-5xl pt-4 md:pt-6"
initial={reduced ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
<ReadingWorkspaceContent />
</motion.div>
</div>
</section>;
}
@@ -1,3 +1,4 @@
import { toLocalDateKey } from "@/shared/utils/date-time";
import type { AnalysisArtifact } from "./workspace-model";
export type WorkspaceTimeRange = { start: string; end: string };
@@ -93,10 +94,3 @@ export function reduceWorkspaceContext(
};
}
}
function toLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
@@ -133,7 +133,7 @@ export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWork
}
export function openAnalysisArtifact(
state: ArtifactWorkspaceState,
_state: ArtifactWorkspaceState,
artifact: AnalysisArtifact
): ArtifactWorkspaceState {
return {