feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { type AgentExecutionPlan } from "@/features/agent";
@@ -130,6 +130,25 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
return { ...props, rerender };
}
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);
const button = screen.getByRole("button", { name: "Agent 协作" });
expect(button).toHaveAttribute("title", "打开调查协作对话,引用此任务,不会直接发送。");
fireEvent.click(button);
expect(props.onContinue).toHaveBeenLastCalledWith({
context: { report_revision: task.latest_report!.revision },
reportGeneration: task.latest_report!.execution_generation
});
rerender(<AgentExecutionPlanPanel {...props} task={{ ...task,
latest_report: { ...task.latest_report!, execution_generation: null }
}} />);
fireEvent.click(screen.getByRole("button", { name: "Agent 协作" }));
expect(props.onContinue).toHaveBeenLastCalledWith({
context: { report_revision: task.latest_report!.revision }, reportGeneration: null
});
});
describe("operational task approval and evidence", () => {
it("shows the selected v2 route first and keeps unchosen work available", () => {
const node = (id: string, title: string, status: "completed" | "waiting" | "skipped") => ({
@@ -294,7 +313,8 @@ describe("operational task approval and evidence", () => {
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
fireEvent.click(screen.getByRole("button", { name: "查看新报告" }));
// Live reading follows published reports without requiring an update click.
expect(screen.queryByRole("button", { name: "查看新报告" })).not.toBeInTheDocument();
expect(screen.getByText(/正在阅读:历史报告/)).toBeVisible();
});
@@ -347,11 +367,24 @@ describe("operational task approval and evidence", () => {
task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }}
{...props}
/>);
// An unassigned historical plan cannot supply a numbered report's diagram.
expect(screen.queryByRole("heading", { name: "调查脉络" })).not.toBeInTheDocument();
expect(within(screen.getByRole("region", { name: "任务报告" }))
.getByText(taskBase.latest_report!.report.current_conclusion, { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask,
latest_plan: { ...latestPlan, execution_generation: null },
latest_report: { ...taskBase.latest_report!, execution_generation: null }
}}
{...props}
/>);
// The legacy relationship remains readable when both records lack a round.
expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible();
expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument();
});
it("shows exact payload and evidence metadata to a viewer without action controls", () => {
it("shows exact payload and evidence metadata to a viewer without action controls", async () => {
taskPanel(taskBase);
expect(screen.getByText("工单标题")).toBeVisible();
expect(screen.getByText("位置")).toBeVisible();
@@ -363,9 +396,10 @@ describe("operational task approval and evidence", () => {
expect(screen.getByText(/当前账号仅可查看/)).toBeVisible();
expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "批准创建预约工单" })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText("主要依据").querySelector("summary")!);
expect(screen.getByText("SCADA 水质监测")).toBeVisible();
expect(screen.getByText("系统数据")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开主要依据" }));
fireEvent.click(screen.getByRole("dialog", { name: "主要依据" }).querySelector("summary")!);
await waitFor(() => expect(within(screen.getByRole("dialog", { name: "主要依据" })).getByText("SCADA 水质监测")).toBeVisible());
expect(within(screen.getByRole("dialog", { name: "主要依据" })).getByText("系统数据")).toBeVisible();
});
it("keeps pending state after a decision network error", async () => {
@@ -449,7 +483,7 @@ describe("operational task approval and evidence", () => {
actions: [completedAction]
});
fireEvent.click(screen.getByText("任务时间线"));
fireEvent.click(screen.getByRole("button", { name: "展开任务时间线" }));
const items = [...screen.getByLabelText("任务时间线记录").querySelectorAll("li")];
expect(items.map((item) => item.textContent)).toEqual([
expect.stringContaining("预约工单已创建,等待现场处理"),
@@ -1,4 +1,5 @@
import { InvestigationReportView } from "./investigation-report-view";
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
import { InvestigationTaskReport } from "./investigation-task-report";
import type { InvestigationDiscussion } from "../api/investigation-report";
import type { OperationalTaskReportPage } from "../api/operational-task-client";
@@ -61,6 +62,7 @@ export type ExecutionPlanView = {
};
type AgentExecutionPlanPanelProps = {
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => void;
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
@@ -77,6 +79,7 @@ type AgentExecutionPlanPanelProps = {
};
export function AgentExecutionPlanPanel({
onLocate,
task,
onContinue,
onDecision,
@@ -92,7 +95,7 @@ export function AgentExecutionPlanPanel({
focusActionId
}: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onContinue={onContinue} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId}
onContinue={onContinue} 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} />}
renderEvidence={(item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} />}
@@ -412,7 +415,7 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi
catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); }
finally { setLoading(false); }
};
return <Disclosure label={<span className="line-clamp-2 text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5">
return <Disclosure label={<span className="line-clamp-2 wrap-anywhere text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5">
<div className="border-t border-[var(--surface-divider)]/70 py-2">
<dl className="grid gap-1 text-[11px] leading-5"><EvidenceMeta label="来源" value={item.source_label} /><EvidenceMeta label="时间" value={formatDateTime(item.observed_at)} /><EvidenceMeta label="类型" value={evidenceKindLabel(item.kind)} />{item.source_ref ? <EvidenceMeta label="引用" value={item.source_ref} mono /> : null}</dl>
{item.has_snapshot && !snapshot ? <Button className="mt-2" variant="outline" size="sm" disabled={loading} onClick={() => void load()}>{loading ? <Loader2 className="animate-spin" /> : null}查看数据快照</Button> : null}
@@ -424,7 +427,7 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi
function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) {
const rows = snapshotRows(snapshot.data).slice(0, 12);
return <section aria-label="数据快照" className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-[var(--status-warning-foreground)]">数据已按有界查询范围截断。</p> : null}</section>;
return <section aria-label="数据快照" className="mt-2 min-w-0 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="wrap-anywhere text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-[var(--status-warning-foreground)]">数据已按有界查询范围截断。</p> : null}</section>;
}
function snapshotRows(value: unknown, prefix = ""): [string, string][] {
@@ -434,7 +437,7 @@ function snapshotRows(value: unknown, prefix = ""): [string, string][] {
}
function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return <div className="grid grid-cols-[48px_1fr] gap-2"><dt className="font-semibold text-[var(--ink-muted)]">{label}</dt><dd className={cn("break-words text-slate-700", mono && "font-mono")}>{value}</dd></div>;
return <div className="grid min-w-0 grid-cols-[48px_minmax(0,1fr)] gap-2"><dt className="min-w-0 wrap-anywhere font-semibold text-[var(--ink-muted)]">{label}</dt><dd className={cn("min-w-0 wrap-anywhere text-slate-700", mono && "font-mono")}>{value}</dd></div>;
}
export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, onRefresh }: {
@@ -662,7 +665,7 @@ function dataScopeLabel(scope: string) { return ({
} as Record<string, string>)[scope] ?? scope; }
function taskStatusLabel(status: OperationalTask["status"]) { return { queued: "排队中", running: "处理中", waiting_human: "待人工", completed: "已完成", failed: "失败", cancelled: "已取消" }[status]; }
function evidenceKindLabel(kind: OperationalTaskEvidence["kind"]) { return { system_data: "系统数据", external_feedback: "外部反馈", human_input: "人工提供", synthetic: "合成演练" }[kind]; }
function formatDateTime(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); }
function formatDateTime(value: string) { return formatDisplayDateTime(value, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); }
function toLocalDateTime(value: unknown) {
if (typeof value !== "string") return "";
const date = new Date(value);
@@ -0,0 +1,166 @@
import { ChevronDown, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { useId, useLayoutEffect, useRef } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-search-client";
const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]";
function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) {
return (
<>
{index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]">
{String(index + 1).padStart(2, "0")}
</span> : null}
<span className="min-w-0 flex-1 space-y-1">
<span className="flex items-start justify-between gap-2 leading-5">
<span className="min-w-0 flex-1 break-words font-semibold text-[var(--ink)]">{item.name}</span>
<span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span>
</span>
<span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span>
</span>
</>
);
}
export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding }) {
const inputId = useId();
const statusId = useId();
const candidateListRef = useRef<HTMLUListElement | null>(null);
const changeSelectionRef = useRef<HTMLButtonElement | null>(null);
const { address, result, results, pending, error, enabled } = geocoding;
const selectionCollapsed = Boolean(result && !geocoding.choosing);
const hideSelectionHint = Boolean(results?.items.length && enabled && !error);
useLayoutEffect(() => {
if (selectionCollapsed || !result) return;
const list = candidateListRef.current;
const selected = list?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
if (!list || !selected) return;
const viewport = list.getBoundingClientRect();
const row = selected.getBoundingClientRect();
list.scrollTop += row.top - viewport.top - (list.clientHeight - row.height) / 2;
}, [selectionCollapsed, result, results]);
const status = error
?? (pending ? "正在搜索地名…"
: !enabled ? "等待地图加载后即可定位"
: result ? "已在地图标出所选位置"
: results ? results.items.length ? "选择一个候选位置进行定位" : "奉贤区内未找到匹配位置,请细化关键字"
: "搜索奉贤区内地址或地名,支持回车查询");
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<form
className="flex shrink-0 flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
void geocoding.search();
}}
>
<label htmlFor={inputId} className="block px-1 text-xs font-semibold text-[var(--ink-muted)]">
查询地址
</label>
<div className="flex items-start gap-2">
<Input
id={inputId}
value={address}
onChange={(event) => geocoding.changeAddress(event.target.value)}
placeholder="道路、门牌号或地名"
className="h-10 min-w-0 flex-1 rounded-xl border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
autoComplete="street-address"
maxLength={120}
aria-invalid={Boolean(error)}
aria-describedby={statusId}
/>
<Button
type="submit"
variant="outline"
aria-label="搜索地名"
className="shrink-0 gap-1.5 px-3 text-xs"
disabled={!enabled || !address.trim()}
loading={pending}
loadingLabel="正在搜索地名"
>
<Search aria-hidden="true" />查询
</Button>
</div>
<div
id={statusId}
role={error ? "alert" : "status"}
aria-live={error ? "assertive" : "polite"}
className={hideSelectionHint ? "sr-only" : cn("flex min-h-5 items-start gap-1.5 px-1 text-[11px] leading-5", error ? "text-[var(--status-danger-foreground)]" : "text-[var(--ink-muted)]")}
>
{result && !error ? <MapPin size={13} className="mt-0.5 shrink-0" aria-hidden="true" /> : null}
<span>{status}</span>
</div>
</form>
{results && results.items.length > 0 ? (
<section aria-label="搜索结果" className="flex min-h-0 flex-1 flex-col gap-2 border-t border-[var(--surface-divider)] pt-3">
<div className="flex h-10 shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]">
<span className="text-xs font-semibold">{selectionCollapsed ? "已选位置" : "选择位置"}</span>
{selectionCollapsed ? (
<Button ref={changeSelectionRef} type="button" variant="secondary" className="gap-1.5 px-3 text-xs" aria-expanded={false}
onClick={(event) => {
geocoding.changeSelection();
if (event.detail === 0) window.requestAnimationFrame(() => {
const list = candidateListRef.current;
const selected = list?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
(selected ?? list?.querySelector<HTMLButtonElement>("button"))?.focus({ preventScroll: true });
});
}}>
更换位置<ChevronDown aria-hidden="true" />
</Button>
) : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
</div>
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
{results.items.map((item, index) => (
<li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0">
<Button type="button" variant="ghost" aria-pressed={result?.id === item.id} data-selection-tone="soft"
aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled}
className={cn("h-auto min-h-16 w-full justify-start gap-3 rounded-lg border-0 whitespace-normal px-1 py-3 text-left text-xs focus-visible:ring-inset focus-visible:ring-offset-0 [@media(hover:hover)]:hover:bg-[var(--surface-control)] [@media(hover:hover)]:hover:text-[var(--ink)]",
result?.id === item.id && "bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] [&_span]:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-selection-soft)]")}
onClick={(event) => {
geocoding.select(item.id);
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
}}>
<PlaceSummary item={item} index={index} />
</Button>
</li>
))}
</ul> : null}
{result && selectionCollapsed ? (
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div className="space-y-3">
<div className="flex min-h-16 items-center gap-3 border-b border-[var(--surface-divider)] px-1 py-3 text-xs">
<PlaceSummary item={result} annotation={result.category ?? result.district} />
</div>
<dl aria-label="经纬度坐标" className="space-y-1 px-1">
<dt className="text-[11px] leading-4 text-[var(--ink-muted)]">经度 / 纬度</dt>
<dd className="whitespace-nowrap text-sm font-medium leading-5 tabular-nums text-[var(--ink)]">
{`${result.longitude.toFixed(6)}, ${result.latitude.toFixed(6)}`}
</dd>
</dl>
</div>
</div>
<div className="grid shrink-0 grid-cols-2 gap-2">
<Button type="button" variant="secondary" className="text-xs" disabled={!enabled} onClick={geocoding.locate}>
<LocateFixed aria-hidden="true" />重新定位
</Button>
<Button type="button" variant="secondary" className="text-xs" onClick={geocoding.clear}>
<Trash2 aria-hidden="true" />清除结果
</Button>
</div>
</section>
) : null}
<p className="flex min-h-5 shrink-0 items-start gap-1.5 px-1 text-[11px] leading-5 text-[var(--ink-muted)]">
{selectionCollapsed ? <><MapPin size={13} className="mt-0.5 shrink-0" aria-hidden="true" /><span>已在地图标出所选位置</span></>
: results.truncated ? `最多显示 ${MAX_PLACE_SEARCH_RESULTS} 个位置,请细化关键词`
: "选择一个候选位置进行定位"}
</p>
</section>
) : null}
</div>
);
}
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
import { GitBranch, Maximize2, RotateCcw, X, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { MessageResponse } from "@/shared/ai-elements/message";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import type { InvestigationDiagram } from "../api/investigation-report";
import type { InvestigationDiagram, InvestigationMapTarget } from "../api/investigation-report";
import { InvestigationExpandCard } from "./investigation-expand-card";
import {
INVESTIGATION_DIAGRAM_FONT,
investigationDiagramConfig,
@@ -26,13 +27,10 @@ function controlIcon(Icon: LucideIcon, control: string) {
};
}
// The icon markers also identify controls in Streamdown's fullscreen portal.
const diagramControlIcons = {
ZoomInIcon: controlIcon(ZoomIn, "zoom-in"),
ZoomOutIcon: controlIcon(ZoomOut, "zoom-out"),
RotateCcwIcon: controlIcon(RotateCcw, "reset"),
Maximize2Icon: controlIcon(Maximize2, "fullscreen"),
XIcon: controlIcon(X, "close")
RotateCcwIcon: controlIcon(RotateCcw, "reset")
};
function DiagramUnavailable() {
@@ -49,11 +47,15 @@ const getNodeId = (node: Element | null | undefined) =>
export function InvestigationDiagramView({
diagram,
renderDetails,
onAsk
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[]) => void;
}) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
@@ -74,12 +76,8 @@ export function InvestigationDiagramView({
active = false;
};
}, [diagram.mermaid]);
return (
<section aria-label="调查脉络" className="investigation-report-section">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<GitBranch size={15} aria-hidden />
{diagram.title}
</h3>
const content = (close: (restoreFocus?: boolean) => void) => (
<>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
</p>
@@ -108,22 +106,21 @@ export function InvestigationDiagramView({
<MessageResponse
icons={diagramControlIcons}
plugins={investigationDiagramPlugins}
translations={{
viewFullscreen: "全屏查看",
exitFullscreen: "退出全屏"
}}
mermaid={{
errorComponent: DiagramUnavailable,
config: investigationDiagramConfig
}}
controls={{
mermaid: { fullscreen: true, download: false, copy: false, panZoom: true }
mermaid: { fullscreen: false, download: false, copy: false, panZoom: true }
}}
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram)}\n\`\`\``}</MessageResponse>
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram, running)}\n\`\`\``}</MessageResponse>
) : (
<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)]"
@@ -175,14 +172,23 @@ 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}
{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={() => onAsk(selected)}>
<Button size="sm" variant="outline" onClick={() => { close(false); onAsk(selected); }}>
围绕此节点提问
</Button>
) : null}
</div>
) : null}
</section>
</>
);
return (
<InvestigationExpandCard title={diagram.title} label="调查脉络" preview={null} reuseContent wide triggerLabel="放大查看">
{content}
</InvestigationExpandCard>
);
}
@@ -0,0 +1,132 @@
import { useCallback, useId, 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 { Maximize2, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
/** Report-specific progressive disclosure. The source stays mounted while reading. */
export function InvestigationExpandCard({
title, label = title, preview, children, wide = false, onOpen, contentRef,
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false
}: {
title: string;
label?: string;
preview: ReactNode;
children: ReactNode | ((close: (restoreFocus?: boolean) => void) => ReactNode);
wide?: boolean;
onOpen?: () => void;
contentRef?: Ref<HTMLElement>;
triggerLabel?: string;
className?: string;
/** Freeze versioned prose while reading; live controls must not use this. */
revision?: string;
disabled?: boolean;
/** Move one live content host between slots, preserving expensive renderers. */
reuseContent?: boolean;
}) {
const id = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef(true);
const [open, setOpen] = useState(false);
const [keyboard, setKeyboard] = useState(false);
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 attachPreview = useCallback((slot: HTMLDivElement | null) => {
previewSlot.current = slot;
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
}, [contentHost]);
const attachDetail = useCallback((slot: HTMLDivElement | null) => {
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;
slot.appendChild(contentHost);
} else {
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) => {
setOpen(next);
if (next) {
restoreFocusRef.current = true;
setReading(revision === undefined ? null : { revision, content: children });
onOpen?.();
}
}}>
<motion.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">
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
<Dialog.Trigger 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>
</div>
{/* Contain child margins so the occupied and vacated slots measure alike. */}
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
</motion.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>
);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import { Eye, EyeOff, LocateFixed, X } from "lucide-react";
import { AgentChartView } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
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;
onToggleLayer: (id: string, visible: boolean) => void;
};
export function InvestigationPresentationPanel({ task, 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>
</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>;
}
@@ -1,5 +1,7 @@
import { FileText } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import { formatDateTime } from "@/shared/utils/date-time";
import { formatInvestigationText } from "../utils/investigation-text";
import type { OperationalTask } from "../api/operational-task-client";
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
@@ -24,25 +26,25 @@ export function InvestigationReportView({ task, reportLabel }: { task: Operation
<p className="text-xs font-semibold leading-5 text-slate-900">
{!current ? reportLabel : insufficient ? insufficientLabel : report.stage === "interim" ? "阶段性判断" : report.outcome === "concluded" ? "调查结论" : "历史报告内容"}
</p>
<p className="mt-1 break-words text-xs leading-6 text-slate-700">{report.current_conclusion}</p>
{insufficient ? <p className="mt-1 break-words text-xs leading-5 text-[var(--status-warning-foreground)]">结束原因:{report.stop_reason}</p> : null}
<p className="mt-1 break-words text-xs leading-6 text-slate-700">{formatInvestigationText(report.current_conclusion)}</p>
{insufficient ? <p className="mt-1 break-words text-xs leading-5 text-[var(--status-warning-foreground)]">结束原因:{formatInvestigationText(report.stop_reason)}</p> : null}
</div>
{report.data_gaps && report.data_gaps.length > 0 ? <div aria-label="数据缺口" className="space-y-2">
<h4 className="text-xs font-semibold text-slate-800">{insufficient ? "关键数据缺口" : "数据范围限制"}</h4>
{report.data_gaps.map((gap) => <div key={gap.gap_id} className="space-y-1 border-l border-[var(--surface-divider)] pl-3 text-xs leading-5">
<p className="break-words font-medium text-slate-900">{gap.missing_data}{gap.blocking ? " · 阻止形成结论" : ""}</p>
<p className="break-words text-slate-600">查询范围:{gap.scope}</p>
<p className="break-words text-slate-600">判断影响:{gap.impact}</p>
<p className="break-words text-slate-600">需补齐:{gap.required_data}</p>
<p className="break-words text-[var(--ink-muted)]">依据:{gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";")}</p>
<p className="break-words font-medium text-slate-900">{formatInvestigationText(gap.missing_data)}{gap.blocking ? " · 阻止形成结论" : ""}</p>
<p className="break-words text-slate-600">查询范围:{formatInvestigationText(gap.scope)}</p>
<p className="break-words text-slate-600">判断影响:{formatInvestigationText(gap.impact)}</p>
<p className="break-words text-slate-600">需补齐:{formatInvestigationText(gap.required_data)}</p>
<p className="break-words text-[var(--ink-muted)]">依据:{formatInvestigationText(gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";"))}</p>
</div>)}
</div> : report.data_gaps == null ? <p className="text-[11px] leading-5 text-[var(--ink-muted)]">历史记录未提供结构化数据缺口。</p> : null}
{!insufficient && report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
{!insufficient && report.pending_items.length > 0 ? <ReportItems title="待确认事项" items={report.pending_items} /> : null}
{insufficient ? <p className="text-xs leading-5 text-slate-600">补齐上述数据后可发起新一轮调查。原投诉工单与现场处置进度独立记录。</p> : null}
<div className="space-y-1 border-t border-[var(--surface-divider)]/70 pt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
<p className="break-words">结论边界:{report.conclusion_boundary}</p>
<p>报告第 {snapshot.revision} 版 · {new Date(snapshot.published_at).toLocaleString("zh-CN", { hour12: false })}</p>
<p className="break-words">结论边界:{formatInvestigationText(report.conclusion_boundary)}</p>
<p>报告第 {snapshot.revision} 版 · {formatDateTime(snapshot.published_at)}</p>
</div>
</> : <p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>}
</section>;
@@ -50,6 +52,6 @@ export function InvestigationReportView({ task, reportLabel }: { task: Operation
function ReportItems({ title, items }: { title: string; items: string[] }) {
return <div><h4 className="text-xs font-semibold text-slate-800">{title}</h4>
<ul className="mt-1 list-disc space-y-1 pl-4 text-xs leading-5 text-slate-700">{items.map((item, index) => <li key={index} className="break-words">{item}</li>)}</ul>
<ul className="mt-1 list-disc space-y-1 pl-4 text-xs leading-5 text-slate-700">{items.map((item, index) => <li key={index} className="break-words">{formatInvestigationText(item)}</li>)}</ul>
</div>;
}
@@ -1,9 +1,11 @@
import { useEffect, useState, type ReactNode } from "react";
import { FileText, History, MessageSquare } from "lucide-react";
import { MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge } from "@/shared/ui/status";
import type { InvestigationDiagram, InvestigationDiscussion } from "../api/investigation-report";
import { formatDateTime as dateLabel } from "@/shared/utils/date-time";
import { formatInvestigationText } from "../utils/investigation-text";
import type { InvestigationDiagram, InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
import type {
OperationalTask,
OperationalTaskAction,
@@ -17,6 +19,8 @@ import {
isPendingInvestigationAction
} from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view";
import { InvestigationExpandCard } from "./investigation-expand-card";
import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
@@ -27,9 +31,9 @@ type Props = {
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
onLocate?: (targets: InvestigationMapTarget[]) => void;
};
const dateLabel = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
const roundLabel = (snapshot: OperationalTaskReport) =>
snapshot.execution_generation == null ? "历史报告" : `第 ${snapshot.execution_generation} 轮`;
const actionTitle = (action: OperationalTaskAction) =>
@@ -123,27 +127,35 @@ export function InvestigationTaskReport({
renderEvidence,
onLoadReports,
onReportViewed,
focusActionId
focusActionId,
onLocate
}: Props) {
const [selection, setSelection] = useState<ReportSelection | null>(() =>
task.latest_report
? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) }
: null
);
// 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[]>([]);
const [historyOffset, setHistoryOffset] = useState<number | null>(0);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const snapshot = selection?.snapshot ?? null;
const live = selection === null;
const snapshot = selection?.snapshot ?? task.latest_report;
const report = snapshot?.report;
const diagram = selection?.diagram ?? (snapshot ? null : investigationDiagram(task, null));
const progress = task.latest_progress?.execution_generation === task.execution_generation ? task.latest_progress : null;
// A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress.
const currentFinalReport = snapshot?.execution_generation === task.execution_generation
&& report?.stage === "final";
const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
: selection.diagram;
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
const previousRound = Boolean(
snapshot && snapshot.execution_generation !== task.execution_generation
);
const olderRevision = Boolean(
snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
);
const latestAvailable = Boolean(task.latest_report && (!snapshot || olderRevision));
const latestAvailable = !live;
const insufficient = report?.outcome === "insufficient_data";
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
@@ -153,14 +165,8 @@ export function InvestigationTaskReport({
const majorEvidence = (snapshot ? evidence : task.evidence).slice(0, 3);
const remainingEvidence = (snapshot ? evidence : task.evidence).slice(3);
const timeline = investigationTimeline(task, snapshot);
useEffect(() => {
if (!selection && task.latest_report)
setSelection({
snapshot: task.latest_report,
diagram: investigationDiagram(task, task.latest_report)
});
}, [selection, task]);
const reportRef = useReportUpdateMotion(task.task_id,
`${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live);
useEffect(() => {
if (snapshot?.execution_generation == null || !onReportViewed) return;
@@ -182,6 +188,7 @@ export function InvestigationTaskReport({
const discuss = (prompt: string, extra?: { evidence_id?: string; gap_id?: string }) =>
onContinue({
prompt,
reportGeneration: snapshot?.execution_generation,
context: { ...(snapshot ? { report_revision: snapshot.revision } : {}), ...extra }
});
const selectReport = (item: OperationalTaskReport) =>
@@ -202,7 +209,7 @@ export function InvestigationTaskReport({
};
return (
<article aria-label="调查任务报告" className="investigation-report surface-reading">
<article ref={reportRef} aria-label="调查任务报告" className="investigation-report surface-reading">
<header className="investigation-report-header">
<div className="min-w-0">
<p className="text-xs text-[var(--ink-muted)]">
@@ -227,8 +234,9 @@ export function InvestigationTaskReport({
<Button
variant="outline"
size="sm"
title="打开调查协作对话,引用此任务,不会直接发送。"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {} })
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {}, reportGeneration: snapshot?.execution_generation })
}
>
<MessageSquare size={14} aria-hidden />
@@ -238,19 +246,16 @@ export function InvestigationTaskReport({
{latestAvailable ? (
<div className="investigation-report-update" role="status">
<span>第 {task.latest_report!.execution_generation ?? "未知"} 轮有新报告</span>
<Button variant="outline" size="sm" onClick={() => selectReport(task.latest_report!)}>
查看新报告
<span>正在阅读历史报告{olderRevision ? ",已有新报告" : ""}</span>
<Button variant="outline" size="sm" onClick={() => setSelection(null)}>
返回实时内容
</Button>
</div>
) : null}
<section aria-label="任务报告" className="investigation-report-section">
<InvestigationExpandCard title="当前结果" label="任务报告" disabled={!report}
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`} preview={<>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<FileText size={15} aria-hidden />
当前结果
</h3>
{report ? (
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient
@@ -282,37 +287,42 @@ export function InvestigationTaskReport({
? "调查结论"
: "历史报告内容"}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--ink)]">{report.current_conclusion}</p>
<p className="investigation-conclusion-preview mt-1 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
结论边界:{report.conclusion_boundary}
结论边界:{formatInvestigationText(report.conclusion_boundary)}
</p>
{insufficient ? (
<p className="mt-2 text-sm leading-6 text-[var(--status-warning-foreground)]">
结束原因:{report.stop_reason}
结束原因:{formatInvestigationText(report.stop_reason)}
</p>
) : null}
<p className="mt-2 text-xs text-[var(--ink-muted)]">
正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "}
{dateLabel(snapshot.published_at)}
</p>
{!insufficient &&
(report.recommendations.length > 0 || report.pending_items.length > 0) ? (
<Disclosure label="判断说明与建议" className="mt-2">
{report.recommendations.length > 0 ? (
<ReportItems title="处置建议" items={report.recommendations} />
) : null}
{report.pending_items.length > 0 ? (
<ReportItems title="待核实问题" items={report.pending_items} />
) : null}
</Disclosure>
) : null}
</>
) : (
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">
尚未发布本轮调查报告。已保存的调查资料可在下方查看。
</p>
)}
</section>
</>}>
{report && snapshot ? <>
<p className="text-xs text-[var(--ink-muted)]">{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 · {dateLabel(snapshot.published_at)}</p>
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">结论边界:{formatInvestigationText(report.conclusion_boundary)}</p>
{insufficient ? <p className="mt-3 text-sm leading-6 text-[var(--status-warning-foreground)]">结束原因:{formatInvestigationText(report.stop_reason)}</p> : <>
{report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
{report.pending_items.length > 0 ? <ReportItems title="待核实问题" items={report.pending_items} /> : null}
</>}
</> : <p className="text-sm text-[var(--ink-muted)]">尚未发布本轮调查报告。</p>}
</InvestigationExpandCard>
{live ? <section aria-label="当前调查进展" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">当前调查进展</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}</p>
{progress ? <p className="mt-1 text-xs text-[var(--ink-muted)]">实时内容 · 第 {progress.execution_generation} 轮 · 更新 {dateLabel(progress.published_at)}</p> : null}
</section> : null}
{pendingActions.length > 0 ? (
<section aria-label="当前需处理" className="investigation-report-section">
@@ -387,13 +397,13 @@ export function InvestigationTaskReport({
</h3>
{report.data_gaps.map((gap) => (
<div key={gap.gap_id} className="mt-3">
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{gap.missing_data}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{gap.impact}</p>
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{formatInvestigationText(gap.missing_data)}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{formatInvestigationText(gap.impact)}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-4">
<Disclosure label="查看查询依据">
<div className="space-y-1 py-2 text-xs leading-5 text-[var(--ink-muted)]">
<p>查询范围:{gap.scope}</p>
<p>需补齐:{gap.required_data}</p>
<p>查询范围:{formatInvestigationText(gap.scope)}</p>
<p>需补齐:{formatInvestigationText(gap.required_data)}</p>
{gap.evidence_refs.map((id) => (
<EvidenceReference
key={id}
@@ -409,7 +419,7 @@ export function InvestigationTaskReport({
size="sm"
onClick={() =>
discuss(
`我要补充“${gap.missing_data}”相关的现场事实。请说明需要的记录范围和时间信息。`,
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`,
{ gap_id: gap.gap_id }
)
}
@@ -433,8 +443,10 @@ export function InvestigationTaskReport({
{diagram ? (
<InvestigationDiagramView
key={`${snapshot?.revision ?? "plan"}:${diagram.mermaid}`}
key={live ? `live:${task.task_id}` : `report:${snapshot?.revision}`}
diagram={diagram}
running={diagramRunning}
onLocate={onLocate}
renderDetails={(node) => (
<>
{node.evidence_refs.map((id) => (
@@ -473,22 +485,43 @@ export function InvestigationTaskReport({
/>
) : null}
<section aria-label="主要依据" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">主要依据</h3>
<InvestigationExpandCard title="主要依据" disabled={task.evidence.length === 0} preview={<>
<div className="investigation-evidence-preview">
{majorEvidence.map((item) => <div key={item.evidence_id}>
<p className="text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(item.summary)}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.source_label} · {dateLabel(item.observed_at ?? item.recorded_at)}</p>
<Button type="button" variant="outline" size="sm" className="mt-2" onClick={() => discuss(
`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, { evidence_id: item.evidence_id }
)}><MessageSquare size={14} aria-hidden />围绕此证据提问</Button>
</div>)}
</div>
<p className="mt-2 text-xs text-[var(--ink-muted)]">
{(snapshot ? evidence : task.evidence).length > 0
? `共 ${(snapshot ? evidence : task.evidence).length} 条引用依据,展开查看来源与原始记录。`
: "尚无可展示的引用证据。"}
</p>
</>}>
{(close) => <>
{majorEvidence.map((item) => (
<div key={item.evidence_id} className="mt-2">
{renderEvidence(item)}
<button
{onLocate && diagram?.node_refs.some((node) => node.evidence_refs.includes(item.evidence_id) && node.map_targets?.length) ? (
<button type="button" className="investigation-text-action" onClick={() => { close(false); onLocate(diagram.node_refs.filter((node) => node.evidence_refs.includes(item.evidence_id)).flatMap((node) => node.map_targets ?? [])); }}>在地图查看</button>
) : null}
<Button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”这条证据的范围、来源和判断影响。`, {
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</button>
</Button>
</div>
))}
{(snapshot ? evidence : task.evidence).length === 0 ? (
@@ -499,17 +532,20 @@ export function InvestigationTaskReport({
{remainingEvidence.map((item) => (
<div key={item.evidence_id}>
{renderEvidence(item)}
<button
<Button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”的来源和判断影响。`, {
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”的来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</button>
</Button>
</div>
))}
</Disclosure>
@@ -522,45 +558,31 @@ export function InvestigationTaskReport({
.map(renderEvidence)}
</Disclosure>
) : null}
</section>
</>}
</InvestigationExpandCard>
<section className="investigation-report-section">
<Disclosure label="任务时间线">
<p className="pb-2 text-xs leading-5 text-[var(--ink-muted)]">
按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。
</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{timeline.map((item) => (
<li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">
{dateLabel(item.occurredAt)}
</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.detail}</p>
</li>
))}
</ol>
</Disclosure>
<InvestigationExpandCard title="任务时间线" disabled={timeline.length === 0} preview={
<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{timeline[0] ? `${timeline[0].title} · ${dateLabel(timeline[0].occurredAt)},共 ${timeline.length} 条记录。` : "尚无调查记录。"}
</p>
}>
<InvestigationTimeline items={timeline} />
</InvestigationExpandCard>
{onLoadReports ? (
<Disclosure
label={
<span className="flex items-center gap-2">
<History size={14} aria-hidden />
报告版本
</span>
}
onToggle={(event) => {
if (event.currentTarget.open && history.length === 0 && !historyLoading)
void loadHistory(0);
}}
<InvestigationExpandCard title="报告版本"
preview={<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{snapshot ? `${roundLabel(snapshot)} · 报告第 ${snapshot.revision} 版 · ${dateLabel(snapshot.published_at)}` : "尚未发布报告。"}
</p>}
onOpen={() => { if (history.length === 0 && !historyLoading) void loadHistory(0); }}
>
{(close) => <>
{history.map((item) => (
<button
key={item.revision}
type="button"
className="investigation-history-row"
aria-pressed={snapshot?.revision === item.revision}
onClick={() => selectReport(item)}
onClick={() => { selectReport(item); close(); }}
>
<span>
{roundLabel(item)} · 报告第 {item.revision} 版
@@ -593,16 +615,30 @@ export function InvestigationTaskReport({
{historyError ? "重新加载" : "更多报告"}
</Button>
) : null}
</Disclosure>
</>}
</InvestigationExpandCard>
) : null}
<p className="mt-3 text-xs text-[var(--ink-muted)]">
<p className="px-4 pb-4 pt-1 text-xs text-[var(--ink-muted)]">
任务更新于 {dateLabel(task.updated_at)}
</p>
</section>
</article>
);
}
function InvestigationTimeline({ items }: { items: InvestigationTimelineItem[] }) {
return <>
<p className="pb-3 text-xs leading-5 text-[var(--ink-muted)]">按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{items.map((item) => <li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">{dateLabel(item.occurredAt)}</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{formatInvestigationText(item.detail)}</p>
</li>)}
</ol>
{items.length === 0 ? <p className="text-sm text-[var(--ink-muted)]">尚无调查记录。</p> : null}
</>;
}
function EvidenceReference({
id,
task,
@@ -626,7 +662,7 @@ function ReportItems({ title, items }: { title: string; items: string[] }) {
<p className="font-medium">{title}</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{items.map((item, index) => (
<li key={index}>{item}</li>
<li key={index}>{formatInvestigationText(item)}</li>
))}
</ul>
</div>
@@ -9,7 +9,25 @@ import { MapDevPanel } from "./map-dev-panel";
afterEach(cleanup);
describe("MapDevPanel flow controls", () => {
describe("MapDevPanel", () => {
it("defaults to following investigations and allows disabling it", async () => {
const user = userEvent.setup();
const onFollowChange = vi.fn();
render(
<MapDevPanel
commands={createCommands()}
controllerState={createControllerState()}
detailFeature={null}
onClose={vi.fn()}
onInvestigationFollowChange={onFollowChange}
/>
);
const follow = screen.getByRole("checkbox", { name: "跟随所选调查" });
expect(follow).toBeChecked();
await user.click(follow);
expect(onFollowChange).toHaveBeenCalledWith(false);
});
it("updates a pipe with its signed velocity", async () => {
const user = userEvent.setup();
const commands = createCommands();
@@ -23,6 +41,7 @@ describe("MapDevPanel flow controls", () => {
/>
);
await user.click(screen.getByText("水流调试"));
const pipeId = screen.getByLabelText("管道 ID");
const velocity = screen.getByLabelText("有符号流速");
await user.clear(pipeId);
@@ -37,7 +56,8 @@ describe("MapDevPanel flow controls", () => {
});
});
it("shows the current flow direction counts", () => {
it("shows the current flow direction counts", async () => {
const user = userEvent.setup();
render(
<MapDevPanel
commands={createCommands()}
@@ -55,9 +75,12 @@ describe("MapDevPanel flow controls", () => {
/>
);
expect(screen.getByRole("status", {
name: ""
})).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1");
await user.click(screen.getByText("水流调试"));
expect(
screen.getByRole("status", {
name: ""
})
).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1");
});
});
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import {
@@ -30,6 +30,8 @@ type MapDevPanelProps = {
detailFeature: DetailFeature | null;
flowDataSource?: "realtime" | "mock";
onClose: () => void;
investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void;
};
const GROUP_LABELS = Object.fromEntries(
@@ -102,7 +104,9 @@ export function MapDevPanel({
controllerState,
detailFeature,
flowDataSource = "mock",
onClose
onClose,
investigationFollow = true,
onInvestigationFollowChange
}: MapDevPanelProps) {
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681");
@@ -231,13 +235,18 @@ 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] flex-col overflow-hidden rounded-2xl border lg:flex"
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"
data-testid="map-dev-panel"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--surface-divider)]/70 px-4">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
<p className="text-[11px] text-[var(--ink-muted)]">仅本次页面会话生效</p>
<h2 className="text-sm font-semibold text-foreground">
地图调试{" "}
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span>
</h2>
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
按功能展开 · 设置仅本次页面会话生效
</p>
</div>
<Button
size="icon"
@@ -250,18 +259,40 @@ export function MapDevPanel({
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<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 rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
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}
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
{onInvestigationFollowChange ? (
<PanelSection
title="调查联动"
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
initiallyOpen
status={investigationFollow ? "已开启" : "已关闭"}
>
<ToggleField
label="跟随所选调查"
checked={investigationFollow}
onChange={onInvestigationFollowChange}
/>
</PanelSection>
) : null}
<PanelSection
title="要素定位"
description="读取目标要素后,执行地图高亮或相机定位。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.target ? "已高亮" : undefined
}
>
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2">
<FieldSelect
label="业务源"
value={sourceId}
@@ -336,10 +367,13 @@ export function MapDevPanel({
</PanelSection>
<PanelSection
title="全网水流"
description={flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"}
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
@@ -407,8 +441,9 @@ export function MapDevPanel({
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowSummary.reverse} · 停止 {flowSummary.stopped}
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
@@ -418,8 +453,13 @@ export function MapDevPanel({
</PanelSection>
<PanelSection
title="样式编辑"
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
label="业务图层组"
@@ -519,91 +559,6 @@ export function MapDevPanel({
) : null}
</>
)}
<div className="space-y-2.5 rounded-xl bg-[var(--surface-control)]/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
<p className="mt-0.5 text-[10px] leading-4 text-[var(--ink-muted)]">
作用于当前图层所有可见 Feature,自动避让重叠。
</p>
</div>
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
MVT 原生
</span>
</div>
<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}
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}
</div>
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
@@ -621,6 +576,95 @@ export function MapDevPanel({
<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}
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>
</div>
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
@@ -631,7 +675,7 @@ export function MapDevPanel({
variant="outline"
>
<RotateCcw size={14} />
重置全部
重置地图调试效果
</Button>
</div>
</aside>
@@ -641,27 +685,43 @@ export function MapDevPanel({
function PanelSection({
title,
description,
initiallyOpen = false,
status,
children
}: {
title: string;
description: string;
initiallyOpen?: boolean;
status?: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 border-b border-[var(--surface-divider)]/70 px-1 pb-4 last:border-b-0 last:pb-1">
<div>
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] leading-4 text-[var(--ink-muted)]">{description}</p>
<Disclosure
initiallyOpen={initiallyOpen}
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground"
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)]">
{status}
</span>
) : null}
</span>
}
>
<div className="space-y-3 px-2 pb-3 pt-1">
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p>
{children}
</div>
{children}
</section>
</Disclosure>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block min-w-0">
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span>
{children}
</label>
);
@@ -677,11 +737,10 @@ function ToggleField({
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-[var(--surface-reading)]/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<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">
<Checkbox
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="size-4 rounded"
/>
{label}
</label>
@@ -699,14 +758,15 @@ function ColorField({
}) {
return (
<Field label={label}>
<div className="flex h-9 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-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
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 text-slate-600">{value}</span>
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span>
</div>
</Field>
);
@@ -737,7 +797,7 @@ function RangeField({
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-6 w-full accent-[var(--action-blue)]"
className="h-10 w-full rounded accent-[var(--action-blue)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/>
</Field>
);
@@ -747,7 +807,7 @@ function StatusText({ children }: { children: React.ReactNode }) {
return (
<p
role="status"
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
className="status-tone-success break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]"
>
{children}
</p>
@@ -755,4 +815,4 @@ function StatusText({ children }: { 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-slate-800 transition-colors";
"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";
@@ -25,6 +25,7 @@ import type {
OperationalTaskDataSnapshot
} 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,
@@ -54,7 +55,7 @@ import {
getStatusActivity,
getStatusTone,
getRiskLabel,
statusLabels
getConditionStatusLabel
} from "./scheduled-condition-feed-utils";
import { getConditionReportRiskLevel } from "./condition-report-risk";
@@ -108,6 +109,7 @@ type ExecutionStep = {
};
type ConditionDetailPanelProps = {
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
condition: ScheduledConditionItem;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
@@ -155,12 +157,13 @@ export function StatusPill({
}
className={className}
>
{insufficient ? "已结束 · 证据不足" : isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
{insufficient ? "已结束 · 证据不足" : isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : getConditionStatusLabel(condition)}
</StatusBadge>
);
}
export function ConditionDetailPanel({
onLocate,
condition,
dataSource = "mock",
workOrderDataSource = "mock",
@@ -188,6 +191,7 @@ export function ConditionDetailPanel({
/>
) : (
<ConditionRecordDetailPanel
onLocate={onLocate}
condition={condition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
@@ -223,6 +227,7 @@ export function ConditionDetailPanel({
}
function ConditionRecordDetailPanel({
onLocate,
condition,
dataSource,
workOrderDataSource,
@@ -239,6 +244,7 @@ function ConditionRecordDetailPanel({
onReportViewed,
focusActionId
}: {
onLocate?: ConditionDetailPanelProps["onLocate"];
condition: ScheduledConditionRecord;
dataSource: WorkbenchRuntimeDataSource;
workOrderDataSource: WorkbenchWorkOrderDataSource;
@@ -309,6 +315,7 @@ function ConditionRecordDetailPanel({
) : condition.operationalTask ? (
<>
<AgentExecutionPlanPanel
onLocate={onLocate}
task={condition.operationalTask}
onLoadReports={onLoadTaskReports ? (offset) => onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined}
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
@@ -1447,10 +1454,9 @@ function getWorkOrderSourceStatus(condition: ScheduledWorkOrderItem): NonNullabl
}
function formatWorkOrderDateTime(value: string) {
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? value : new Intl.DateTimeFormat("zh-CN", {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false
}).format(timestamp);
return formatDateTime(value, {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
});
}
function formatWorkOrderScheduledAt(condition: ScheduledWorkOrderItem) {
@@ -3,11 +3,46 @@ import {
groupScheduledConditions,
getStatusActivity,
getStatusTone,
getConditionStatusLabel,
createStatusFilterOptions,
formatTime,
formatTimeRange,
sortConditionsNewestFirst
} from "./scheduled-condition-feed-utils";
import type { ScheduledConditionItem, ScheduledWorkOrderItem } from "../types";
import type { OperationalTask } from "../api/operational-task-client";
describe("workbench UTC+8 time display", () => {
it.each([
["2026-10-08T04:30:00Z", "12:30"],
["2026-10-08T04:30:00+00:00", "12:30"],
["2026-10-08T12:30:00+08:00", "12:30"],
["2026-10-08T16:00:00Z", "00:00"]
])("displays %s as %s without depending on the browser timezone", (value, expected) => {
expect(formatTime(value)).toBe(expected);
});
it("uses the same timezone for both ends of a range, including midnight", () => {
expect(formatTimeRange("2026-10-08T04:30:00Z", 60)).toBe("12:30 - 13:30");
expect(formatTimeRange("2026-10-08T15:30:00Z", 60)).toBe("23:30 - 00:30");
expect(formatTimeRange("2026-10-08T12:30:00+08:00", 0)).toBe("12:30 - 12:30");
});
it("preserves invalid display values without producing an invalid end date", () => {
expect(formatTimeRange("未安排", 60)).toBe("未安排");
});
});
describe("scheduled condition status presentation", () => {
it("labels queued investigations as waiting without changing appointment wording", () => {
const base = createCondition("investigation", "condition", "2026-10-10T10:29:47Z");
if (base.kind !== "condition") throw new Error("Expected investigation fixture");
const condition = { ...base, status: "pending" as const,
operationalTask: { task_type: "event_investigation" } as OperationalTask };
expect(getConditionStatusLabel(condition)).toBe("待调查");
expect(createStatusFilterOptions([condition]).find((option) => option.value === "pending")?.label).toBe("待调查");
expect(getConditionStatusLabel(createWorkOrder("appointment"))).toBe("预约");
});
it("maps lifecycle states to the shared semantic language", () => {
expect({
pending: getStatusTone("pending"),
@@ -6,6 +6,7 @@ import {
XCircle
} from "lucide-react";
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
export { formatTime, formatTimeRange } from "@/shared/utils/date-time";
import type {
ScheduledConditionItem,
ScheduledConditionItemStatus
@@ -23,6 +24,12 @@ export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
pending: "预约"
};
export function getConditionStatusLabel(condition: ScheduledConditionItem) {
return condition.status === "pending" && condition.kind === "condition"
&& condition.operationalTask?.task_type === "event_investigation"
? "待调查" : statusLabels[condition.status];
}
const statusTones = {
completed: "success",
running: "info",
@@ -111,7 +118,9 @@ export function createStatusFilterOptions(conditions: ScheduledConditionItem[]):
return conditionStatusOrder.map((status) => ({
value: status,
label: statusLabels[status],
label: status === "pending" && conditions.some((condition) =>
condition.kind === "condition" && condition.operationalTask?.task_type === "event_investigation"
) ? "待调查" : statusLabels[status],
count: statusCounts.get(status) ?? 0
}));
}
@@ -120,22 +129,6 @@ export function getConditionTime(condition: ScheduledConditionItem) {
return Date.parse(condition.scheduledAt);
}
export function formatTime(value: string) {
const match = value.match(/T(\d{2}:\d{2})/);
return match?.[1] ?? value;
}
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
const startTime = formatTime(value);
if (typeof durationMinutes !== "number") {
return startTime;
}
const endDate = new Date(Date.parse(value) + durationMinutes * 60_000);
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
return `${startTime} - ${endTime}`;
}
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
if (status === "running") {
return "执行中";
@@ -94,6 +94,7 @@ type ScheduledConditionFeedProps = {
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onLoadTaskReports?: (taskId: string, offset: number) => Promise<OperationalTaskReportPage>;
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
onRefresh?: () => void | Promise<void>;
};
@@ -133,6 +134,7 @@ export function ScheduledConditionFeed({
onLoadTaskEvidenceSnapshot,
onTaskRefresh,
onLoadTaskReports,
onLocate,
onReportViewed,
onRefresh
}: ScheduledConditionFeedProps) {
@@ -140,10 +142,14 @@ export function ScheduledConditionFeed({
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
string | null
>(null);
const [retainedScheduledCondition, setRetainedScheduledCondition] = useState<ScheduledConditionItem | null>(null);
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("scheduled");
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>(() => {
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
return selected ? getWorkbenchTaskCategory(selected) ?? "scheduled" : "scheduled";
});
const [refreshing, setRefreshing] = useState(false);
const refreshingRef = useRef(false);
const mobileSheet = presentation === "mobile-sheet";
@@ -156,7 +162,14 @@ export function ScheduledConditionFeed({
() => groupConditionsByTaskCategory(conditions),
[conditions]
);
const categoryConditions = conditionsByCategory[taskCategory];
const categoryConditions = useMemo(() => {
const current = conditionsByCategory[taskCategory];
return taskCategory === "scheduled"
&& retainedScheduledCondition?.id === selectedConditionId
&& !current.some((condition) => condition.id === selectedConditionId)
? [...current, retainedScheduledCondition]
: current;
}, [conditionsByCategory, retainedScheduledCondition, selectedConditionId, taskCategory]);
const activeLoading = taskCategory === "scheduled" ? loading : backendLoading;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(categoryConditions),
@@ -244,11 +257,18 @@ export function ScheduledConditionFeed({
return;
}
// A controlled task selection survives panel remounts and detail-loading windows.
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
if (selected) {
const category = getWorkbenchTaskCategory(selected);
if (category && category !== taskCategory) { setTaskCategory(category); return; }
} else if (controlledSelectedConditionId && (loading || backendLoading)) return;
const nextSelectedConditionId = selectedCondition?.id ?? null;
if (selectedConditionId !== nextSelectedConditionId) {
updateSelectedConditionId(nextSelectedConditionId);
}
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
}, [backendLoading, conditions, controlledSelectedConditionId, expanded, loading, selectedCondition?.id, selectedConditionId, taskCategory, updateSelectedConditionId]);
useEffect(() => {
if (!focusConditionId || focusRequestId === undefined) {
@@ -268,6 +288,7 @@ export function ScheduledConditionFeed({
setTaskCategory(targetCategory);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
setRetainedScheduledCondition(targetCategory === "scheduled" ? targetCondition : null);
updateSelectedConditionId(targetCondition.id);
setFocusedAction(focusRequest?.actionId ? { conditionId: targetCondition.id, actionId: focusRequest.actionId } : null);
updateExpanded(true);
@@ -293,6 +314,7 @@ export function ScheduledConditionFeed({
setTaskCategory(nextCategory);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
setRetainedScheduledCondition(null);
updateSelectedConditionId(
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
);
@@ -329,6 +351,11 @@ export function ScheduledConditionFeed({
}
function handleSelectTimelineCondition(conditionId: string) {
if (taskCategory === "scheduled") {
setRetainedScheduledCondition(
categoryConditions.find((condition) => condition.id === conditionId) ?? null
);
}
updateSelectedConditionId(conditionId);
if (!expanded) {
updateExpanded(true);
@@ -521,6 +548,7 @@ export function ScheduledConditionFeed({
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
onLocate={onLocate}
condition={selectedCondition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
@@ -46,8 +46,10 @@ import {
type WorkbenchMeasureUnit
} from "../hooks/use-workbench-measurement";
import { waterNetworkToolbarItems } from "../map/toolbar-config";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { GeocodingPanel } from "./geocoding-panel";
export type ToolbarToolId = "layers" | "measure" | "analysis" | "annotation" | "more";
export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k";
type MeasureModeOption = {
@@ -95,6 +97,7 @@ const DRAW_TOOL_OPTIONS: Array<{
];
export type ToolbarPanelProps = {
geocoding: WorkbenchGeocoding;
activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit;
@@ -136,6 +139,7 @@ export type ToolbarPanelProps = {
};
export function ToolbarPanel({
geocoding,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -225,6 +229,19 @@ export function ToolbarPanel({
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
const Icon = activeTool?.icon ?? Layers3;
if (renderedToolId === "geocoding") {
return (
<MapControlPanel
key="geocoding" title="地址定位" description="奉贤区 · 天地图地名搜索" icon={Icon}
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
contentClassName="flex min-h-0 flex-1 flex-col"
visible={panelVisible}
>
<GeocodingPanel geocoding={geocoding} />
</MapControlPanel>
);
}
if (renderedToolId === "layers") {
return (
<MapControlPanel
@@ -152,7 +152,7 @@ export function AlertMenu({
"border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
compact && compactHeaderControlButtonClassName
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
aria-label={`查看待办与告警,当前 ${alerts.length} 条待处理事项`}
>
<span
className={cn(
@@ -164,7 +164,7 @@ export function AlertMenu({
>
<Bell size={14} aria-hidden="true" />
</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>待办与告警</span>
{compact ? (
<StatusBadge
tone={alertSummaryTone}
@@ -193,7 +193,7 @@ export function AlertMenu({
sourceLabel={sourceLabel}
tone={hasAlerts ? alertSummaryTone : "success"}
/>
<MenuSectionLabel>待复核工况</MenuSectionLabel>
<MenuSectionLabel>待处理事项</MenuSectionLabel>
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger";
@@ -228,8 +228,8 @@ export function AlertMenu({
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
<CheckCircle2 size={16} />
</SemanticStatusIcon>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的工况提醒会进入这里。</p>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
</div>
</div>
)}
@@ -349,15 +349,15 @@ function AlertQueueSummary({
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
</SemanticStatusIcon>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">待办与告警</h2>
<p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
{hasAlerts ? `${count} 条待处理${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待处理事项"}
</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">来源:{sourceLabel}</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">业务告警来源:{sourceLabel}</p>
</div>
</div>
<StatusBadge tone={tone}>
{hasAlerts ? "需复核" : "正常"}
{hasAlerts ? "待处理" : "暂无待办"}
</StatusBadge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-[var(--ink-muted)]">
@@ -264,7 +264,7 @@ export function WorkbenchTopBar({
function getAlertSourceLabel(source: WorkbenchAlertDataSource) {
if (source === "backend") return "后端运行态";
if (source === "mock") return "工况演示回退";
return "未配置告警源";
return "未配置";
}
function getAnalysisOriginLabel(origin: AnalysisArtifact["origin"]) {