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:
@@ -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"]) {
|
||||
|
||||
Reference in New Issue
Block a user