feat: expand intelligent investigation workspace
This commit is contained in:
@@ -130,6 +130,35 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
|
||||
return { ...props, rerender };
|
||||
}
|
||||
|
||||
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
|
||||
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
|
||||
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
|
||||
run_id: "70000000-0000-4000-8000-000000000002", source_session_id: "s", execution_generation: taskBase.execution_generation,
|
||||
status: "pending", version: 1, created_at: taskBase.created_at, answered_at: null, answered_by: null, answers: [],
|
||||
questions: [{ question_id: "sampling", title: "是否有采样记录", body: "请补充采样信息", options: [], mode: "single", allow_custom: true, required: true }] }],
|
||||
latest_report: { ...taskBase.latest_report!, report: { ...taskBase.latest_report!.report,
|
||||
data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段", impact: "待核验",
|
||||
required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }] } }
|
||||
};
|
||||
const props = taskPanel(task);
|
||||
const questions = screen.getByRole("region", { name: "调查提问" });
|
||||
questions.scrollIntoView = vi.fn();
|
||||
fireEvent.click(screen.getByRole("button", { name: "回答当前调查问题" }));
|
||||
expect(questions).toHaveFocus();
|
||||
expect(questions.scrollIntoView).toHaveBeenCalled();
|
||||
expect(props.onContinue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels free discussion separately when no fixed question is pending", () => {
|
||||
const props = taskPanel({ ...taskBase, latest_report: { ...taskBase.latest_report!, report: {
|
||||
...taskBase.latest_report!.report, data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段",
|
||||
impact: "待核验", required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }]
|
||||
} } });
|
||||
expect(screen.queryByRole("button", { name: "补充事实" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "讨论此缺口" }));
|
||||
expect(props.onContinue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("references the selected report round instead of the latest task round, including legacy reports", () => {
|
||||
const task = { ...taskBase, execution_generation: 2 };
|
||||
const { rerender, ...props } = taskPanel(task);
|
||||
@@ -191,7 +220,7 @@ it("opens the execution record bound to the selected report round", async () =>
|
||||
fireEvent.click(await screen.findByRole("button", { name: /第 1 轮 · 报告第 1 版/ }));
|
||||
expect(screen.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByText("调查任务 · 第 2 轮", { exact: true })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "执行记录", hidden: true }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "执行记录" }));
|
||||
|
||||
await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({
|
||||
executionGeneration: 1,
|
||||
|
||||
@@ -75,6 +75,7 @@ type AgentExecutionPlanPanelProps = {
|
||||
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onRefresh: () => void;
|
||||
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (generation: number, revision: number) => void;
|
||||
focusActionId?: string | null;
|
||||
@@ -94,11 +95,12 @@ export function AgentExecutionPlanPanel({
|
||||
onLoadEvidenceSnapshot,
|
||||
onRefresh,
|
||||
onLoadReports,
|
||||
onAnswerQuestion,
|
||||
onReportViewed,
|
||||
focusActionId
|
||||
}: AgentExecutionPlanPanelProps) {
|
||||
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
|
||||
onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
|
||||
onAnswerQuestion={onAnswerQuestion} onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
|
||||
onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
|
||||
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
|
||||
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
@@ -148,7 +149,7 @@ export function AnalysisHistorySection({
|
||||
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
|
||||
{item.runTypeLabel} · {formatLocalMonthDayTime(item.createdAt)} · {item.createdBy}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
{item.hasResults ? "已有持久化结果" : "仅有运行记录"}
|
||||
@@ -187,15 +188,7 @@ function statusTone(status: string): StatusTone {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function formatHistoryTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "分析成果历史请求失败";
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Target, TriangleAlert, X } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
getFeatureInsightMetrics,
|
||||
getLocalizedFeatureProperties
|
||||
} from "../utils/feature-properties";
|
||||
import { FeaturePropertyList } from "./feature-property-list";
|
||||
|
||||
type FeatureInsightPanelProps = {
|
||||
feature: DetailFeature | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
||||
const metrics = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getFeatureInsightMetrics(feature.layer, feature.properties);
|
||||
}, [feature]);
|
||||
|
||||
const propertyEntries = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getLocalizedFeatureProperties(feature.properties);
|
||||
}, [feature]);
|
||||
|
||||
return (
|
||||
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||
<div className="border-b border-[var(--surface-divider)]/80 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-[0.14em] text-[var(--ink-muted)]">要素详情</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-slate-950">
|
||||
{feature ? feature.title : "选择一个管网对象"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭详情"
|
||||
title="关闭详情"
|
||||
onClick={onClose}
|
||||
className="surface-control grid h-9 w-9 place-items-center rounded border text-[var(--ink-muted)] hover:text-slate-900"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性和 Agent 解释。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{feature ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{metrics.map(({ label, value }) => (
|
||||
<div key={label} className="surface-reading rounded border p-3">
|
||||
<p className="text-xs text-[var(--ink-muted)]">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="material-tone-agent mt-4 rounded border border-[var(--surface-border)] p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--action-blue-pressed)]">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
Agent 解释
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||
<div className="mt-2">
|
||||
<FeaturePropertyList entries={propertyEntries} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-[var(--ink-muted)]">
|
||||
<Target size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
||||
<p className="mt-1 max-w-[260px] text-sm leading-6 text-[var(--ink-muted)]">
|
||||
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={`grid h-10 w-10 place-items-center rounded border transition ${
|
||||
active
|
||||
? "border-slate-800 bg-slate-900 text-white"
|
||||
: "surface-control border-transparent text-slate-700 hover:bg-[var(--surface-reading)]"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "@/test/radix-select-test-setup";
|
||||
import { createInvestigationDevCase } from "../utils/investigation-dev-cases";
|
||||
import { InvestigationDevTools } from "./investigation-dev-tools";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("investigation Dev case selection", () => {
|
||||
it("previews a loaded case in one click without an investigation or separate validation step", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPreview = vi.fn();
|
||||
render(
|
||||
<InvestigationDevTools
|
||||
task={null}
|
||||
mapReady
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const cases = screen.getByRole("combobox", { name: "JSONL 测试案例" });
|
||||
expect(cases).toBeEnabled();
|
||||
await user.click(cases);
|
||||
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
|
||||
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
|
||||
"DEMO-PIPE-01"
|
||||
);
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commands: [] }),
|
||||
expect.any(String)
|
||||
);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
|
||||
"候选管段对比"
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
|
||||
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
|
||||
expect(cases).toHaveTextContent("候选管段对比");
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
it("previews data without a map but waits before executing map commands", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPreview = vi.fn();
|
||||
render(
|
||||
<InvestigationDevTools
|
||||
task={null}
|
||||
mapReady={false}
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={vi.fn()}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("调查展示 JSONL");
|
||||
await user.click(input);
|
||||
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
|
||||
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commands: [] }),
|
||||
expect.any(String)
|
||||
);
|
||||
await user.clear(input);
|
||||
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
|
||||
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("地图尚未就绪");
|
||||
expect(onPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(["invalid_json", "invalid_command", "invalid_chart"] as const)(
|
||||
"validates %s on execute and never runs a partial batch",
|
||||
async (id) => {
|
||||
const user = userEvent.setup();
|
||||
const onPreview = vi.fn();
|
||||
render(
|
||||
<InvestigationDevTools
|
||||
task={null}
|
||||
mapReady
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("调查展示 JSONL");
|
||||
await user.click(input);
|
||||
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
id === "invalid_json" ? "第 2 行" : "第 1 行"
|
||||
);
|
||||
expect(onPreview).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
|
||||
await user.clear(input);
|
||||
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
|
||||
await user.click(execute);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commands: [] }),
|
||||
expect.any(String)
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { Check, Download, FlaskConical, Play, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import {
|
||||
investigationDevCases,
|
||||
type InvestigationDevCaseId
|
||||
} from "../utils/investigation-dev-cases";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import {
|
||||
investigationActionLabels,
|
||||
investigationReportJsonl,
|
||||
investigationReportUiSummary,
|
||||
parseInvestigationDevJsonl,
|
||||
type InvestigationDevPreview,
|
||||
type InvestigationPresentationActivity
|
||||
} from "../utils/investigation-dev-preview";
|
||||
|
||||
export type InvestigationDevToolsProps = {
|
||||
task: OperationalTask | null;
|
||||
mapReady: boolean;
|
||||
charts: number;
|
||||
overlay: string | null;
|
||||
activity: InvestigationPresentationActivity[];
|
||||
onSample: (caseId?: InvestigationDevCaseId) => string;
|
||||
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export function InvestigationDevTools({
|
||||
task,
|
||||
mapReady,
|
||||
charts,
|
||||
overlay,
|
||||
activity,
|
||||
onSample,
|
||||
onPreview,
|
||||
onClear
|
||||
}: InvestigationDevToolsProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [input, setInput] = useState("");
|
||||
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
|
||||
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
|
||||
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState(0);
|
||||
const previewActive = useRef(false);
|
||||
const inputId = useId();
|
||||
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
|
||||
|
||||
function replaceInput(value: string, nextCaseId?: InvestigationDevCaseId) {
|
||||
setInput(value);
|
||||
setCaseId(nextCaseId);
|
||||
setChecked(null);
|
||||
setPreview(null);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setSelectedRecord(0);
|
||||
}
|
||||
function clearLoadedPreview() {
|
||||
if (!previewActive.current) return;
|
||||
onClear();
|
||||
previewActive.current = false;
|
||||
}
|
||||
function loadCase(nextCaseId: InvestigationDevCaseId) {
|
||||
if (running || !mapReady) return;
|
||||
clearLoadedPreview();
|
||||
replaceInput(onSample(nextCaseId), nextCaseId);
|
||||
}
|
||||
const selectedCase = investigationDevCases.find((item) => item.id === caseId);
|
||||
|
||||
function validate() {
|
||||
try {
|
||||
const parsed = parseInvestigationDevJsonl(input);
|
||||
setChecked(parsed);
|
||||
setError(null);
|
||||
setMessage(
|
||||
`已识别 ${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
|
||||
);
|
||||
return parsed;
|
||||
} catch (cause) {
|
||||
setChecked(null);
|
||||
setError(cause instanceof Error ? cause.message : "JSONL 校验失败。");
|
||||
setMessage(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
if (running || !input.trim()) return;
|
||||
const parsed = validate();
|
||||
if (!parsed) return;
|
||||
if (parsed.commands.length > 0 && !mapReady) {
|
||||
setError("地图尚未就绪,请稍后再执行本地预览。");
|
||||
setMessage(null);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setPreview(parsed);
|
||||
previewActive.current = true;
|
||||
try {
|
||||
await onPreview(parsed, selectedCase?.label ?? "JSONL");
|
||||
setMessage(
|
||||
`本地预览完成:${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError"))
|
||||
setError(cause instanceof Error ? cause.message : "本地展示失败。");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
function download() {
|
||||
if (!checked) return;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([`${input.trim()}\n`], { type: "application/x-ndjson;charset=utf-8" })
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `investigation-preview-${task?.execution_generation ?? "local"}.jsonl`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4" data-testid="investigation-dev-tools">
|
||||
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">当前调查</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-8 px-0 text-xs"
|
||||
aria-expanded={open}
|
||||
aria-controls="investigation-dev-lab"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<FlaskConical size={14} />
|
||||
{open ? "收起动作测试" : "测试地图、图表与 JSONL"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="break-words text-sm font-semibold text-foreground">
|
||||
{task ? task.title : "尚未选择调查"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{task
|
||||
? `第 ${task.execution_generation} 轮 · 实时图表 ${charts} · 报告图表 ${summary.charts} · 表格 ${summary.tables} · 地图入口 ${summary.maps}`
|
||||
: "可直接载入、校验和执行本地案例;选择调查后可观察实时展示。"}
|
||||
</p>
|
||||
{task?.latest_report ? (
|
||||
<p className="text-muted-foreground">
|
||||
已保存报告第 {task.latest_report.revision} 版 · 第{" "}
|
||||
{task.latest_report.execution_generation ?? "未知"} 轮
|
||||
{task.latest_report.execution_generation !== task.execution_generation
|
||||
? ",上一轮内容"
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
{overlay ? <p className="break-words text-muted-foreground">覆盖层:{overlay}</p> : null}
|
||||
{summary.invalid ? (
|
||||
<p className="text-[var(--status-warning-foreground)]">
|
||||
{summary.invalid} 个报告界面块校验未通过。
|
||||
</p>
|
||||
) : null}
|
||||
{task && !task.latest_report ? (
|
||||
<p className="text-muted-foreground">本轮尚无报告,实时联动只处理选中后的新指令。</p>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
|
||||
<h3 className="text-xs font-semibold">01 准备数据</h3>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
|
||||
</p>
|
||||
<FieldSelect
|
||||
label="JSONL 测试案例"
|
||||
placeholder="选择案例,立即载入 JSONL"
|
||||
value={caseId ?? ""}
|
||||
onValueChange={(value) => {
|
||||
const nextCase = investigationDevCases.find((item) => item.id === value);
|
||||
if (nextCase) loadCase(nextCase.id);
|
||||
}}
|
||||
options={investigationDevCases.map((item) => ({ value: item.id, label: item.label }))}
|
||||
disabled={!mapReady || running}
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
/>
|
||||
{!mapReady ? (
|
||||
<p className="text-[11px] text-muted-foreground">地图正在初始化,就绪后可载入案例。</p>
|
||||
) : null}
|
||||
{selectedCase ? (
|
||||
<p aria-label="案例预期结果" className="text-xs leading-5 text-muted-foreground">
|
||||
预期:{selectedCase.expected}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
切换即载入,执行时自动校验;已执行的本地预览会清除。
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!mapReady || running}
|
||||
onClick={() => loadCase(caseId ?? "combined")}
|
||||
>
|
||||
载入合成样例
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!task || !summary.blocks || running}
|
||||
onClick={() => {
|
||||
if (task) {
|
||||
clearLoadedPreview();
|
||||
replaceInput(investigationReportJsonl(task));
|
||||
}
|
||||
}}
|
||||
>
|
||||
载入当前报告
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 border-t border-border pt-4">
|
||||
<h3 className="pb-2 text-xs font-semibold">02 校验与执行</h3>
|
||||
<label htmlFor={inputId} className="text-xs font-medium">
|
||||
调查展示 JSONL
|
||||
</label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
value={input}
|
||||
disabled={running}
|
||||
onChange={(event) => replaceInput(event.target.value)}
|
||||
rows={5}
|
||||
maxLength={200_000}
|
||||
spellCheck={false}
|
||||
className="min-h-28 resize-y font-mono text-[11px] leading-5"
|
||||
placeholder="每行一条展示指令、界面块或带来源的调查数据"
|
||||
/>
|
||||
</div>
|
||||
<Disclosure
|
||||
label="JSONL 格式说明"
|
||||
className="[&>summary]:min-h-8 [&>summary]:text-muted-foreground"
|
||||
>
|
||||
<p className="pb-2 text-[11px] leading-5 text-muted-foreground">
|
||||
presentation 执行受控展示,ui_block 预览界面;candidate
|
||||
等调查数据仅展示,不自动操作地图。
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!input.trim() || running}
|
||||
onClick={validate}
|
||||
>
|
||||
<Check size={14} />
|
||||
校验 JSONL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="text-xs"
|
||||
disabled={!input.trim() || running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<Play size={14} />
|
||||
{running ? "正在预览" : "执行本地预览"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
点击执行会自动校验当前 JSONL,整批校验通过后才预览;也可单独校验。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">03 检查结果</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
disabled={!checked || running}
|
||||
onClick={download}
|
||||
>
|
||||
<Download size={14} />
|
||||
下载测试 JSONL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
onClear();
|
||||
previewActive.current = false;
|
||||
setPreview(null);
|
||||
setMessage("已清除当前浏览器的临时展示。");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
清除临时展示
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{message ? (
|
||||
<p role="status" className="break-words text-xs leading-5 text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="break-words text-xs leading-5 text-[var(--status-danger-foreground)]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{preview ? (
|
||||
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
业务结果已在展示面板打开,可切换动作历史回看。
|
||||
</p>
|
||||
<Disclosure label="原始数据检查">
|
||||
{preview.records.length ? (
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<table className="w-full text-left text-[11px] leading-5">
|
||||
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 pr-3 font-medium">类型</th>
|
||||
<th className="py-1 pr-3 font-medium">对象</th>
|
||||
<th className="py-1 pr-3 font-medium">来源</th>
|
||||
<th className="py-1 text-right font-medium">证据</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.records.map((record, index) => (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
|
||||
aria-label={`查看第 ${index + 1} 条调查数据`}
|
||||
aria-pressed={selectedRecord === index}
|
||||
onClick={() => setSelectedRecord(index)}
|
||||
>
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{String(record.origin)}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{(record.source_evidence_refs as string[]).length}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<pre
|
||||
aria-label="调查数据详情"
|
||||
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
|
||||
>
|
||||
{JSON.stringify(preview.records[selectedRecord], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</Disclosure>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{activity.length ? (
|
||||
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">最近展示动作</h3>
|
||||
<ol
|
||||
aria-label="动作识别记录"
|
||||
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
|
||||
>
|
||||
{activity.slice(-3).map((item) => (
|
||||
<li key={`${item.source}-${item.id}`} className="break-words">
|
||||
<span className="text-muted-foreground">
|
||||
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
|
||||
</span>
|
||||
{investigationActionLabels[item.action]} ·{" "}
|
||||
{item.status === "completed"
|
||||
? "已应用"
|
||||
: item.status === "error"
|
||||
? "未完成"
|
||||
: "展示中"}
|
||||
{item.message ? (
|
||||
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
|
||||
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
|
||||
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
INVESTIGATION_DIAGRAM_FONT,
|
||||
investigationDiagramConfig,
|
||||
investigationDiagramPlugins,
|
||||
investigationDiagramStates,
|
||||
investigationDiagramNodeId as getNodeId,
|
||||
investigationNodeState,
|
||||
paintInvestigationDiagramStates,
|
||||
styleInvestigationDiagram
|
||||
} from "../utils/investigation-diagram-style";
|
||||
|
||||
@@ -41,33 +45,42 @@ function DiagramUnavailable() {
|
||||
);
|
||||
}
|
||||
|
||||
const getNodeId = (node: Element | null | undefined) =>
|
||||
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
|
||||
|
||||
export function InvestigationDiagramView({
|
||||
diagram,
|
||||
renderDetails,
|
||||
onAsk,
|
||||
running = false,
|
||||
onLocate
|
||||
}: {
|
||||
diagram: InvestigationDiagram;
|
||||
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
|
||||
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
|
||||
running?: boolean;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const diagramRoot = useRef<HTMLDivElement>(null);
|
||||
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
|
||||
const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId);
|
||||
const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node, running)))];
|
||||
if (!displayedStates.length) displayedStates.push("unknown");
|
||||
const [fontsReady, setFontsReady] = useState(
|
||||
() => typeof document === "undefined" || !document.fonts
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const root = diagramRoot.current;
|
||||
if (!root) return;
|
||||
const paint = () => paintInvestigationDiagramStates(root, diagram, running);
|
||||
paint();
|
||||
// Mermaid renders asynchronously. Reapply the latest snapshot when it
|
||||
// replaces the SVG; observing children avoids reacting to our own styles.
|
||||
const observer = new MutationObserver(paint);
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, [diagram, running]);
|
||||
useEffect(() => {
|
||||
if (!document.fonts) return;
|
||||
let active = true;
|
||||
void document.fonts
|
||||
.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
|
||||
.load(`500 18px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (active) setFontsReady(true);
|
||||
@@ -82,6 +95,7 @@ export function InvestigationDiagramView({
|
||||
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
|
||||
</p>
|
||||
<div
|
||||
ref={diagramRoot}
|
||||
className="investigation-diagram mt-3"
|
||||
onPointerDownCapture={(event) => {
|
||||
const node = (event.target as Element).closest?.(".node");
|
||||
@@ -104,6 +118,7 @@ export function InvestigationDiagramView({
|
||||
>
|
||||
{fontsReady ? (
|
||||
<MessageResponse
|
||||
key={diagram.mermaid}
|
||||
icons={diagramControlIcons}
|
||||
plugins={investigationDiagramPlugins}
|
||||
mermaid={{
|
||||
@@ -118,36 +133,25 @@ export function InvestigationDiagramView({
|
||||
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
|
||||
)}
|
||||
</div>
|
||||
{running && diagram.node_refs.some((node) => node.status === "running") ? (
|
||||
<p aria-label="图示执行状态" className="mt-2 text-xs text-[var(--action-blue)]">蓝色:正在执行</p>
|
||||
) : null}
|
||||
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
|
||||
aria-label="图示颜色说明"
|
||||
>
|
||||
{diagram.node_refs.some(
|
||||
(node) => node.evidence_refs.length && !node.action_refs.length
|
||||
) ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
|
||||
aria-label="图示状态说明"
|
||||
>
|
||||
{displayedStates.map((key) => {
|
||||
const state = investigationDiagramStates[key];
|
||||
return (
|
||||
<span key={key} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-sm border border-[#6B9E8B] bg-[#EDF7F4]"
|
||||
className="h-2.5 w-3.5 rounded-sm border"
|
||||
style={{ backgroundColor: state.fill, borderColor: state.stroke,
|
||||
borderStyle: state.dash === "0" ? "solid" : "dashed" }}
|
||||
/>
|
||||
引用证据
|
||||
{state.label}
|
||||
</span>
|
||||
) : null}
|
||||
{diagram.node_refs.some((node) => node.action_refs.length) ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-sm border border-[#C4A168] bg-[#FFF6E8]"
|
||||
/>
|
||||
关联业务动作
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{diagram.node_refs.length > 0 ? (
|
||||
<div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5">
|
||||
{diagram.node_refs.map((node) => (
|
||||
@@ -161,6 +165,7 @@ export function InvestigationDiagramView({
|
||||
}
|
||||
>
|
||||
{node.label}
|
||||
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node, running)].label}</span>
|
||||
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
|
||||
</button>
|
||||
))}
|
||||
@@ -172,16 +177,9 @@ export function InvestigationDiagramView({
|
||||
aria-label={`节点详情 ${selected.label}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
|
||||
{selected.status ? <p className="text-xs text-[var(--ink-muted)]">{!running && selected.status === "running" ? "记录时执行中" : {
|
||||
pending: "尚未开始", running: "正在执行", completed: "已完成", waiting_human: "等待人工反馈", failed: "执行失败", skipped: "已跳过", cancelled: "已取消"
|
||||
}[selected.status]}</p> : null}
|
||||
<p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected, running)].label}</p>
|
||||
{renderDetails(selected)}
|
||||
{onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null}
|
||||
{onAsk ? (
|
||||
<Button size="sm" variant="outline" onClick={() => { close(false); onAsk(selected); }}>
|
||||
围绕此节点提问
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||
|
||||
describe("investigation card reading", () => {
|
||||
it("freezes the reading revision until the reader requests the latest content, then restores focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<InvestigationExpandCard title="当前结果" preview={<p>结论一预览</p>} revision="1"><p>结论一正文</p></InvestigationExpandCard>);
|
||||
const trigger = screen.getByRole("button", { name: "展开当前结果" });
|
||||
await user.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "当前结果" });
|
||||
expect(document.body.querySelector(".investigation-detail-backdrop")).toBeInTheDocument();
|
||||
rerender(<InvestigationExpandCard title="当前结果" preview={<p>结论二预览</p>} revision="2"><p>结论二正文</p></InvestigationExpandCard>);
|
||||
expect(dialog).toHaveTextContent("结论一正文");
|
||||
expect(dialog).not.toHaveTextContent("结论二正文");
|
||||
expect(within(dialog).getByRole("status")).toHaveTextContent("当前保留正在阅读的版本");
|
||||
await user.click(within(dialog).getByRole("button", { name: "查看最新内容" }));
|
||||
expect(dialog).toHaveTextContent("结论二正文");
|
||||
expect(within(dialog).queryByRole("status")).not.toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves the same live control into the reader and retains a visible inert preview", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Controls() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button type="button" onClick={() => setCount((value) => value + 1)}>缩放 {count}</button>;
|
||||
}
|
||||
render(<InvestigationExpandCard title="调查脉络" preview={null} reuseContent wide><Controls /></InvestigationExpandCard>);
|
||||
const control = screen.getByRole("button", { name: "缩放 0" });
|
||||
await user.click(control);
|
||||
await user.click(screen.getByRole("button", { name: "展开调查脉络" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "调查脉络" });
|
||||
expect(within(dialog).getByRole("button", { name: "缩放 1" })).toBe(control);
|
||||
const snapshot = document.querySelector("[data-reading-preview]")!;
|
||||
expect(snapshot).toBeVisible();
|
||||
expect(snapshot).toHaveAttribute("aria-hidden", "true");
|
||||
expect(snapshot).toHaveAttribute("inert");
|
||||
await user.click(control);
|
||||
await user.click(within(dialog).getByRole("button", { name: "关闭详情" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("button", { name: "缩放 2" })).toBe(control);
|
||||
expect(document.querySelector("[data-reading-preview]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows a follow-up to own focus when it explicitly closes without restoring the trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Fixture() {
|
||||
const target = useRef<HTMLInputElement>(null);
|
||||
return <><label>继续输入<input ref={target} /></label>
|
||||
<InvestigationExpandCard title="主要依据" preview={<p>依据预览</p>}>
|
||||
{(close) => <button type="button" onClick={() => { close(false); requestAnimationFrame(() => target.current?.focus()); }}>继续核查</button>}
|
||||
</InvestigationExpandCard></>;
|
||||
}
|
||||
render(<Fixture />);
|
||||
await user.click(screen.getByRole("button", { name: "展开主要依据" }));
|
||||
await user.click(screen.getByRole("button", { name: "继续核查" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("继续输入")).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Maximize2, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
|
||||
import { createReadingPreview } from "@/shared/ui/reading-preview";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
/** Report-specific progressive disclosure. The source stays mounted while reading. */
|
||||
@@ -27,7 +28,6 @@ export function InvestigationExpandCard({
|
||||
reuseContent?: boolean;
|
||||
headingAccessory?: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreFocusRef = useRef(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -35,6 +35,7 @@ export function InvestigationExpandCard({
|
||||
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
|
||||
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
|
||||
const previewSlot = useRef<HTMLDivElement | null>(null);
|
||||
const snapshot = useRef<HTMLElement | null>(null);
|
||||
const attachPreview = useCallback((slot: HTMLDivElement | null) => {
|
||||
previewSlot.current = slot;
|
||||
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
|
||||
@@ -43,24 +44,22 @@ export function InvestigationExpandCard({
|
||||
const preview = previewSlot.current;
|
||||
if (!preview || !contentHost) return;
|
||||
if (slot) {
|
||||
// Reserve only the vacated inline space while expanded. The graph itself
|
||||
// keeps its natural height, including after content updates or resizing.
|
||||
// Use the layout height, not a bounding box scaled by the card animation.
|
||||
preview.style.height = getComputedStyle(preview).height;
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = createReadingPreview(contentHost);
|
||||
preview.append(snapshot.current);
|
||||
slot.appendChild(contentHost);
|
||||
} else {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = null;
|
||||
preview.appendChild(contentHost);
|
||||
preview.style.removeProperty("height");
|
||||
}
|
||||
}, [contentHost]);
|
||||
const reduced = useReducedMotion();
|
||||
const duration = reduced || keyboard ? 0 : 0.24;
|
||||
const transition = { duration, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
|
||||
const content = reading?.content ?? children;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(next) => {
|
||||
<Dialog open={open} onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
restoreFocusRef.current = true;
|
||||
@@ -68,69 +67,52 @@ export function InvestigationExpandCard({
|
||||
onOpen?.();
|
||||
}
|
||||
}}>
|
||||
<motion.section
|
||||
<section
|
||||
ref={contentRef}
|
||||
aria-label={label}
|
||||
className={cn("investigation-report-section investigation-expand-card", className)}
|
||||
layoutId={reduced || keyboard ? undefined : id}
|
||||
layoutDependency={open}
|
||||
transition={transition}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<div className="investigation-card-heading">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
|
||||
{headingAccessory}
|
||||
</div>
|
||||
<Dialog.Trigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
|
||||
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
|
||||
<Maximize2 size={15} aria-hidden />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
{/* Contain child margins so the occupied and vacated slots measure alike. */}
|
||||
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
|
||||
</motion.section>
|
||||
</section>
|
||||
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
|
||||
<Dialog.Portal forceMount>
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<Dialog.Overlay forceMount asChild key="backdrop">
|
||||
<motion.div className="investigation-detail-backdrop"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
|
||||
</Dialog.Overlay>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="investigation-detail-positioner" key="detail">
|
||||
<Dialog.Content forceMount asChild aria-describedby={undefined}
|
||||
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
|
||||
<motion.section className={cn("investigation-detail-panel", wide && "investigation-detail-panel-wide")}
|
||||
layoutId={reduced || keyboard ? undefined : id} layoutDependency={open}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={transition} style={{ borderRadius: 12 }}>
|
||||
<header className="investigation-detail-heading">
|
||||
<Dialog.Title className="text-base font-semibold text-[var(--ink)]">{title}</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div className="investigation-detail-scroll">
|
||||
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
|
||||
</div>
|
||||
{reading && revision !== undefined && reading.revision !== revision ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>已有更新,当前保留正在阅读的版本。</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.section>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
<DialogContent
|
||||
className={cn("investigation-detail-panel bg-card", wide && "investigation-detail-panel-wide", keyboard && "investigation-detail-no-motion")}
|
||||
overlayClassName={cn("investigation-detail-backdrop", keyboard && "investigation-detail-no-motion")}
|
||||
data-agent-theme="chat" aria-describedby={undefined} showCloseButton={false}
|
||||
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
|
||||
<header className="investigation-detail-heading border-b border-border">
|
||||
<DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
|
||||
</DialogClose>
|
||||
</header>
|
||||
<div className="investigation-detail-scroll">
|
||||
<motion.div initial={reduced || keyboard ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
|
||||
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
|
||||
</motion.div>
|
||||
</div>
|
||||
{reading && revision !== undefined && reading.revision !== revision ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>已有更新,当前保留正在阅读的版本。</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { forwardRef, useId, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion, useIsPresent } from "motion/react";
|
||||
import { ChevronDown, FileText } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
import {
|
||||
presentationFade,
|
||||
presentationTransition
|
||||
} from "../utils/investigation-presentation-motion";
|
||||
|
||||
// Forward the element ref so AnimatePresence can remove a card without jumping its siblings.
|
||||
export const PresentationItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ immediate: boolean; children: ReactNode }
|
||||
>(function PresentationItem({ immediate, children }, ref) {
|
||||
const present = useIsPresent();
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layout={immediate ? false : "position"}
|
||||
{...presentationFade(immediate)}
|
||||
inert={!present || undefined}
|
||||
aria-hidden={!present || undefined}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PresentationViewport = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ immediate: boolean; id: string; children: ReactNode }
|
||||
>(function PresentationViewport({ immediate, id, children }, ref) {
|
||||
const present = useIsPresent();
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
{...presentationFade(immediate)}
|
||||
inert={!present || undefined}
|
||||
aria-hidden={!present || undefined}
|
||||
id={present ? id : undefined}
|
||||
data-investigation-content={present ? "" : undefined}
|
||||
className="relative min-h-0 space-y-3 overflow-y-auto overscroll-contain p-3 [scrollbar-gutter:stable]"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export function PresentationReferences({
|
||||
entries,
|
||||
immediate
|
||||
}: {
|
||||
entries: Array<{ id: string; text: string }>;
|
||||
immediate: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="mt-3 border-t border-border pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full justify-between gap-2 px-0 text-xs font-normal text-muted-foreground"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<FileText size={13} aria-hidden />
|
||||
查看依据<span className="tabular-nums">{entries.length}</span>
|
||||
</span>
|
||||
<motion.span
|
||||
className="inline-flex"
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
transition={presentationTransition(immediate)}
|
||||
>
|
||||
<ChevronDown size={13} aria-hidden />
|
||||
</motion.span>
|
||||
</Button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.ul
|
||||
key="references"
|
||||
id={id}
|
||||
{...presentationFade(immediate)}
|
||||
className="space-y-2 pb-2 pt-1 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id} className="break-words [overflow-wrap:anywhere]">
|
||||
{entry.text}
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,354 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff, LocateFixed, X } from "lucide-react";
|
||||
import { AgentChartView } from "@/features/agent";
|
||||
import { useId, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
||||
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import {
|
||||
PresentationItem,
|
||||
PresentationViewport,
|
||||
PresentationReferences
|
||||
} from "./investigation-presentation-motion";
|
||||
import {
|
||||
presentationFade,
|
||||
presentationTransition
|
||||
} from "../utils/investigation-presentation-motion";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import type { InvestigationOverlay } from "../api/investigation-presentation";
|
||||
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
|
||||
|
||||
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
|
||||
import { PresentationBusinessResults } from "./presentation-business-results";
|
||||
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
|
||||
type Props = {
|
||||
task: OperationalTask; charts: InvestigationChart[]; overlay: InvestigationOverlay | null;
|
||||
cameraPaused: boolean; status: "connecting" | "live" | "disconnected" | "error"; error: string | null;
|
||||
width: number; relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
|
||||
onResumeCamera: () => void; onCloseChart: (id: string) => void;
|
||||
onToggleOverlay: () => void; onFitOverlay: () => void;
|
||||
blocks?: AnswerUiBlock[];
|
||||
records?: Array<Record<string, unknown>>;
|
||||
evidence?: Array<{ evidence_id: string; summary: string }>;
|
||||
history?: PresentationHistoryControls;
|
||||
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
|
||||
scope?: string;
|
||||
embedded?: boolean;
|
||||
task: OperationalTask | null;
|
||||
localPreview?: boolean;
|
||||
charts: InvestigationChart[];
|
||||
overlay: InvestigationOverlay | null;
|
||||
cameraPaused: boolean;
|
||||
status: "connecting" | "live" | "history" | "disconnected" | "error";
|
||||
error: string | null;
|
||||
width: number;
|
||||
relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
|
||||
onResumeCamera: () => void;
|
||||
onCloseChart: (id: string) => void;
|
||||
onToggleOverlay: () => void;
|
||||
onFitOverlay: () => void;
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
export function InvestigationPresentationPanel({ task, charts, overlay, cameraPaused, status, error, width, relatedLayers,
|
||||
onResumeCamera, onCloseChart, onToggleOverlay, onFitOverlay, onToggleLayer }: Props) {
|
||||
const statusLabels = {
|
||||
connecting: "连接中",
|
||||
live: "实时跟随",
|
||||
history: "已载入历史",
|
||||
disconnected: "连接中断",
|
||||
error: "部分未完成"
|
||||
};
|
||||
|
||||
export function InvestigationPresentationPanel({
|
||||
scope = "investigation",
|
||||
embedded = false,
|
||||
task,
|
||||
blocks = [],
|
||||
records = [],
|
||||
evidence,
|
||||
history,
|
||||
onLocateTargets,
|
||||
localPreview = false,
|
||||
charts,
|
||||
overlay,
|
||||
cameraPaused,
|
||||
status,
|
||||
error,
|
||||
width,
|
||||
relatedLayers,
|
||||
onResumeCamera,
|
||||
onCloseChart,
|
||||
onToggleOverlay,
|
||||
onFitOverlay,
|
||||
onToggleLayer
|
||||
}: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const references = (ids: string[]) => <Disclosure label="查看依据" className="mt-2">
|
||||
{ids.map((id) => <p key={id} className="py-1 text-xs leading-5 text-[var(--ink-muted)]">{task.evidence.find((item) => item.evidence_id === id)?.summary ?? `关联资料:${id}`}</p>)}
|
||||
</Disclosure>;
|
||||
return <aside aria-label="调查实时展示" data-testid="investigation-presentation-panel"
|
||||
className="surface-reading pointer-events-auto absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 max-h-[55dvh] overflow-y-auto rounded-xl border border-[var(--surface-divider)] p-3 text-[var(--ink)] shadow-xs"
|
||||
style={{ width, maxWidth: "calc(100% - 24px)" }}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">调查实时展示</p>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCollapsed(!collapsed)}>{collapsed ? "展开展示" : "收起展示"}</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">{status === "connecting" ? "连接中" : status === "disconnected" ? "连接中断" : status === "error" ? "部分展示未完成" : "跟随所选调查"}</p>
|
||||
{error ? <p role="status" className="mt-2 text-xs text-[var(--status-warning-foreground)]">{error}</p> : null}
|
||||
{cameraPaused ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs"><span>镜头跟随已暂停</span><Button size="sm" variant="outline" onClick={onResumeCamera}>恢复跟随</Button></div> : null}
|
||||
{!collapsed ? <>
|
||||
{relatedLayers.length ? <div aria-label="调查相关图层" className="mt-2 flex flex-wrap gap-1">
|
||||
{relatedLayers.map((layer) => <Button key={layer.id} variant="outline" size="sm" aria-pressed={layer.visible} onClick={() => onToggleLayer(layer.id, !layer.visible)}>{layer.visible ? <Eye size={14} aria-hidden /> : <EyeOff size={14} aria-hidden />}{layer.label}</Button>)}
|
||||
</div> : null}
|
||||
{overlay ? <section aria-label="调查覆盖层" className="mt-3 border-t border-[var(--surface-divider)] pt-3">
|
||||
<p className="text-sm font-medium">{overlay.title}</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">{overlay.mode === "heatmap" ? "记录集中度,颜色不表示已确认影响范围" : "调查范围与关联空间资料"}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onToggleOverlay}>{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}</Button>
|
||||
<Button size="sm" variant="outline" onClick={onFitOverlay}><LocateFixed size={14} aria-hidden />定位覆盖层</Button>
|
||||
const [keyboardAction, setKeyboardAction] = useState(false);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const immediate = Boolean(reducedMotion) || keyboardAction;
|
||||
const contentId = useId();
|
||||
const reviewing = history?.selectedId != null;
|
||||
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
|
||||
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
|
||||
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
|
||||
const references = (ids: string[]) =>
|
||||
ids.length ? (
|
||||
<PresentationReferences
|
||||
immediate={immediate}
|
||||
entries={ids.map((id) => ({
|
||||
id,
|
||||
text:
|
||||
(evidence ?? task?.evidence)?.find((item) => item.evidence_id === id)?.summary ??
|
||||
(localPreview && id === "dev:synthetic"
|
||||
? "合成测试数据,未保存为调查证据。"
|
||||
: `关联资料:${id}`)
|
||||
}))}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
layout={immediate ? false : "size"}
|
||||
{...presentationFade(immediate)}
|
||||
onKeyDownCapture={() => setKeyboardAction(true)}
|
||||
onPointerDownCapture={() => setKeyboardAction(false)}
|
||||
aria-label={title}
|
||||
data-testid="investigation-presentation-panel"
|
||||
data-presentation-scope={scope}
|
||||
data-investigation-collapsed={collapsed}
|
||||
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
|
||||
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
|
||||
>
|
||||
<motion.header
|
||||
layout={immediate ? false : "position"}
|
||||
transition={presentationTransition(immediate)}
|
||||
className="shrink-0 border-b border-border px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h2 className="text-sm font-semibold leading-6">{title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
|
||||
{reviewing
|
||||
? "历史回看"
|
||||
: scope === "chat"
|
||||
? "对话联动"
|
||||
: localPreview
|
||||
? "本地测试"
|
||||
: statusLabels[status]}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
|
||||
</span>
|
||||
{reviewing && pending > 0 ? (
|
||||
<span role="status" className="text-[11px] text-muted-foreground">
|
||||
后续 {pending} 个动作
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
aria-label={collapsed ? "展开展示" : "收起展示"}
|
||||
title={collapsed ? "展开展示" : "收起展示"}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<motion.span
|
||||
className="inline-flex"
|
||||
animate={{ rotate: collapsed ? 180 : 0 }}
|
||||
transition={presentationTransition(immediate)}
|
||||
>
|
||||
<ChevronDown size={16} aria-hidden />
|
||||
</motion.span>
|
||||
</Button>
|
||||
</div>
|
||||
{references([overlay.evidence_id])}
|
||||
</section> : null}
|
||||
{charts.map((item) => <section key={item.chart_id} data-investigation-chart={item.chart_id} className="mt-3 border-t border-[var(--surface-divider)] pt-3">
|
||||
<div className="mb-2 flex justify-end"><Button variant="ghost" size="icon" aria-label={`关闭图表 ${item.chart.title}`} onClick={() => onCloseChart(item.chart_id)}><X size={16} /></Button></div>
|
||||
<AgentChartView chart={item.chart} />
|
||||
{references(item.evidence_refs)}
|
||||
</section>)}
|
||||
</> : null}
|
||||
</aside>;
|
||||
<p
|
||||
className={
|
||||
history?.entries.length
|
||||
? "sr-only"
|
||||
: "mt-1.5 text-[11px] leading-4 text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{selected
|
||||
? `${selected.taskTitle ? `${selected.taskTitle} · ` : ""}${new Date(selected.createdAt).toLocaleTimeString("zh-CN", { hour12: false })} · ${selected.label}${pending ? ` · 后续 ${pending} 个动作` : ""}`
|
||||
: scope === "chat"
|
||||
? "展示当前对话的地图操作与结果"
|
||||
: localPreview
|
||||
? "仅当前浏览器,不写入调查任务"
|
||||
: status === "history"
|
||||
? "已载入所选调查保存的展示结果"
|
||||
: "跟随所选调查,展示地图操作与结果"}
|
||||
</p>
|
||||
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
|
||||
</motion.header>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{!collapsed ? (
|
||||
<PresentationViewport
|
||||
key={history?.selectedId ?? "current"}
|
||||
immediate={immediate}
|
||||
id={contentId}
|
||||
>
|
||||
{selected?.message ? (
|
||||
<p role="status" className="text-xs leading-5 text-muted-foreground">
|
||||
{selected.message}
|
||||
</p>
|
||||
) : null}
|
||||
{!reviewing && !localPreview && error ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md border border-border px-2.5 py-2 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{cameraPaused ? (
|
||||
<PresentationItem key="camera" immediate={immediate}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/50 px-2.5 py-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Pause size={13} aria-hidden />
|
||||
镜头跟随已暂停
|
||||
</span>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onResumeCamera}>
|
||||
{localPreview || scope === "chat" ? "恢复自动定位" : "恢复跟随"}
|
||||
</Button>
|
||||
</div>
|
||||
</PresentationItem>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{relatedLayers.length ? (
|
||||
<section aria-label="调查相关图层" className="space-y-2">
|
||||
<h3 className="text-[11px] font-medium text-muted-foreground">相关图层</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedLayers.map((layer) => (
|
||||
<Button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-8 whitespace-normal text-left"
|
||||
disabled={reviewing}
|
||||
aria-pressed={layer.visible}
|
||||
onClick={() => onToggleLayer(layer.id, !layer.visible)}
|
||||
>
|
||||
{layer.visible ? (
|
||||
<Eye size={14} aria-hidden />
|
||||
) : (
|
||||
<EyeOff size={14} aria-hidden />
|
||||
)}
|
||||
{layer.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{overlay ? (
|
||||
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
|
||||
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
|
||||
<CardHeader className="space-y-1.5 p-3 pb-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPinned
|
||||
size={15}
|
||||
className="mt-0.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="min-w-0 break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||
{overlay.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
{localPreview
|
||||
? "本地空间预览,范围以输入 JSONL 为准"
|
||||
: overlay.mode === "heatmap"
|
||||
? "记录集中度,颜色不表示已确认影响范围"
|
||||
: "调查范围与关联空间资料"}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reviewing}
|
||||
onClick={onToggleOverlay}
|
||||
>
|
||||
{overlay.visible ? (
|
||||
<EyeOff size={14} aria-hidden />
|
||||
) : (
|
||||
<Eye size={14} aria-hidden />
|
||||
)}
|
||||
{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onFitOverlay}>
|
||||
<LocateFixed size={14} aria-hidden />
|
||||
定位覆盖层
|
||||
</Button>
|
||||
</div>
|
||||
{references([overlay.evidence_id])}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PresentationItem>
|
||||
) : null}
|
||||
{charts.map((item) => (
|
||||
<PresentationItem key={item.chart_id} immediate={immediate}>
|
||||
<Card
|
||||
data-investigation-chart={item.chart_id}
|
||||
role="group"
|
||||
aria-label={item.chart.title}
|
||||
className="border-border shadow-none"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||
{item.chart.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||
{item.chart.x_data.length} 个
|
||||
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={reviewing}
|
||||
aria-label={`关闭图表 ${item.chart.title}`}
|
||||
onClick={() => onCloseChart(item.chart_id)}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2 pt-0">
|
||||
<AgentChartView chart={item.chart} embedded />
|
||||
{references(item.evidence_refs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PresentationItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<PresentationBusinessResults
|
||||
blocks={blocks}
|
||||
records={records}
|
||||
reviewing={reviewing}
|
||||
onLocateTargets={onLocateTargets}
|
||||
/>
|
||||
{!charts.length &&
|
||||
!overlay &&
|
||||
!blocks.length &&
|
||||
!records.length &&
|
||||
!relatedLayers.length ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
当前没有展示结果,可切换历史动作回看地图与结果。
|
||||
</p>
|
||||
) : null}
|
||||
</PresentationViewport>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OperationalTask, TaskQuestionRequest } from "../api/operational-task-client";
|
||||
import { InvestigationQuestions } from "./investigation-questions";
|
||||
|
||||
const question: TaskQuestionRequest = {
|
||||
question_request_id: "70000000-0000-4000-8000-000000000001", task_id: "70000000-0000-4000-8000-000000000002",
|
||||
run_id: "70000000-0000-4000-8000-000000000003", source_session_id: "s", execution_generation: 1,
|
||||
status: "pending", version: 1, created_at: "2026-10-30T01:00:00Z", answered_at: null, answered_by: null, answers: [],
|
||||
questions: [{ question_id: "flow", title: "流量依据", body: "是否有流量记录?", options: [{ id: "unknown", label: "目前未知" }], mode: "single", allow_custom: true, required: true }]
|
||||
};
|
||||
const task = { questions: [question], execution_generation: 1, lifecycle_status: "waiting_human", active_run: null,
|
||||
permissions: { can_answer_questions: true } } as OperationalTask;
|
||||
|
||||
describe("investigation questions", () => {
|
||||
it("requires an answer, retains input after failure and retries the same request id", async () => {
|
||||
const onAnswer = vi.fn().mockRejectedValueOnce(new Error("网络失败")).mockResolvedValueOnce({});
|
||||
render(<InvestigationQuestions task={task} onAnswer={onAnswer} />);
|
||||
const submit = screen.getByRole("button", { name: "提交回答并继续调查" });
|
||||
expect(submit).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "目前未知" }));
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "请保留为未知" } });
|
||||
fireEvent.click(submit);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("网络失败");
|
||||
expect(screen.getByRole("textbox")).toHaveValue("请保留为未知");
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAnswer).toHaveBeenCalledTimes(2));
|
||||
expect(onAnswer.mock.calls[0]).toEqual(onAnswer.mock.calls[1]);
|
||||
expect(onAnswer.mock.calls[0].slice(0, 3)).toEqual([question.question_request_id, 1,
|
||||
[{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "请保留为未知" }]]);
|
||||
expect(await screen.findByText(/回答已保存,调查已排队继续/)).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...task, permissions: { ...task.permissions, can_answer_questions: false } },
|
||||
{ ...task, active_run: { status: "running" } },
|
||||
{ ...task, execution_generation: 2 }
|
||||
])("does not answer without permission or for active/stale runs", (value) => {
|
||||
render(<InvestigationQuestions task={value as OperationalTask} onAnswer={vi.fn()} />);
|
||||
expect(screen.getByRole("radio")).toBeDisabled();
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows stored option labels and text after reload without another answer action", () => {
|
||||
render(<InvestigationQuestions task={{ ...task, questions: [{ ...question, status: "answered", version: 2,
|
||||
answers: [{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "没有记录" }] }] }} />);
|
||||
expect(screen.getByText("目前未知;没有记录")).toBeVisible();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useRef, useState, type Ref } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { Radio } from "@/shared/ui/radio";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { createOperationalRequestId, type AnswerTaskQuestion, type OperationalTask,
|
||||
type TaskQuestionAnswer, type TaskQuestionRequest } from "../api/operational-task-client";
|
||||
|
||||
export function InvestigationQuestions({ task, onAnswer, sectionRef }: {
|
||||
task: OperationalTask; onAnswer?: AnswerTaskQuestion; sectionRef?: Ref<HTMLElement>;
|
||||
}) {
|
||||
const questions = task.questions ?? [];
|
||||
if (!questions.length) return null;
|
||||
return <section ref={sectionRef} tabIndex={-1} aria-label="调查提问" className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">补充回答</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">在本报告中选择或填写答案,提交后继续当前调查。</p>
|
||||
{questions.map((request) => <TaskQuestionCard key={`${request.question_request_id}:${request.version}`}
|
||||
request={request} onAnswer={onAnswer}
|
||||
canAnswer={Boolean(task.permissions.can_answer_questions && task.lifecycle_status === "waiting_human"
|
||||
&& !task.active_run && task.execution_generation === request.execution_generation)} />)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function TaskQuestionCard({ request, onAnswer, canAnswer }: {
|
||||
request: TaskQuestionRequest; onAnswer?: AnswerTaskQuestion; canAnswer: boolean;
|
||||
}) {
|
||||
const [answers, setAnswers] = useState<TaskQuestionAnswer[]>(() => request.questions.map((q) => ({
|
||||
question_id: q.question_id, selected_option_ids: [], custom_text: ""
|
||||
})));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const attempt = useRef<{ body: string; id: string } | null>(null);
|
||||
const pending = request.status === "pending" && !submitted;
|
||||
const disabled = !canAnswer || !onAnswer || busy || !pending;
|
||||
const valid = request.questions.every((q, index) => !q.required || answers[index].selected_option_ids.length
|
||||
|| (q.allow_custom && answers[index].custom_text.trim()));
|
||||
const update = (index: number, patch: Partial<TaskQuestionAnswer>) => setAnswers((current) =>
|
||||
current.map((answer, i) => i === index ? { ...answer, ...patch } : answer));
|
||||
const submit = async () => {
|
||||
if (disabled || !valid) return;
|
||||
const body = JSON.stringify(answers);
|
||||
if (attempt.current?.body !== body) attempt.current = { body, id: createOperationalRequestId() };
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await onAnswer!(request.question_request_id, request.version, answers, attempt.current.id);
|
||||
setSubmitted(true);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "提交失败,请重试"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return <article aria-label="调查需要补充的信息" aria-busy={busy} className="space-y-4 rounded-xl border border-border bg-card p-4 text-card-foreground">
|
||||
<p role="status" className="text-xs text-muted-foreground">第 {request.execution_generation} 轮调查 · {submitted || request.status === "answered"
|
||||
? "回答已保存" : request.status === "cancelled" ? "提问已失效" : "等待回答"}</p>
|
||||
{request.questions.map((question, index) => {
|
||||
const answer = answers[index];
|
||||
const saved = request.answers.find((item) => item.question_id === question.question_id);
|
||||
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0 space-y-2">
|
||||
<legend className="text-sm font-medium">{question.title}{question.required && pending ? "(必填)" : ""}</legend>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{question.body}</p>
|
||||
{pending ? <>
|
||||
{question.options.map((option) => <label key={option.id} className="flex min-h-11 items-center gap-2 rounded-lg border border-border p-2 text-sm">
|
||||
{question.mode === "single" ? <Radio name={`${request.question_request_id}:${question.question_id}`}
|
||||
checked={answer.selected_option_ids.includes(option.id)} onChange={() => update(index, { selected_option_ids: [option.id] })} />
|
||||
: <Checkbox checked={answer.selected_option_ids.includes(option.id)} onChange={(event) => update(index, {
|
||||
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id)
|
||||
})} />}
|
||||
<span className="min-w-0 break-words">{option.label}</span>
|
||||
</label>)}
|
||||
{question.allow_custom ? <Textarea aria-label={`${question.title}:补充说明`} maxLength={2000} value={answer.custom_text}
|
||||
placeholder="填写回答或补充说明" onChange={(event) => update(index, { custom_text: event.target.value })} /> : null}
|
||||
</> : <p className="whitespace-pre-wrap break-words text-sm">{saved
|
||||
? [...question.options.filter((o) => saved.selected_option_ids.includes(o.id)).map((o) => o.label), saved.custom_text].filter(Boolean).join(";") || "未填写"
|
||||
: submitted ? "回答已保存,调查已排队继续。" : "该提问不再接受回答。"}</p>}
|
||||
</fieldset>;
|
||||
})}
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
{pending ? <div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">回答将作为用户补充信息供调查参考;提交后继续调查。</p>
|
||||
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => void submit()}>{busy ? "正在提交…" : "提交回答并继续调查"}</Button>
|
||||
</div> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { InvestigationReportBlocks } from "./investigation-report-blocks";
|
||||
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
|
||||
@@ -22,20 +22,16 @@ const snapshot: OperationalTaskReport = {
|
||||
};
|
||||
|
||||
describe("formal report interactive content", () => {
|
||||
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => {
|
||||
it("shows saved evidence facts without quoting or dispatching work", async () => {
|
||||
const onContinue = vi.fn();
|
||||
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
|
||||
expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible());
|
||||
expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "针对这部分提问" }));
|
||||
expect(onContinue).toHaveBeenCalledWith(expect.objectContaining({
|
||||
context: { report_revision: 3 }, reportGeneration: 2,
|
||||
reference: { title: "演练调查报告 · 报告第 3 版 · 监测依据",
|
||||
text: expect.stringContaining("保存的监测来源\n类别:演练数据") },
|
||||
}));
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
|
||||
expect(screen.queryByText("引用依据:保存的监测来源")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
@@ -44,7 +40,28 @@ describe("formal report interactive content", () => {
|
||||
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByText("分析待核验。")).toBeVisible();
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
|
||||
});
|
||||
it("keeps references not already represented by an evidence card", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].evidence_refs.push("other-evidence");
|
||||
const expandedTask = { evidence: [...task.evidence, { ...task.evidence[0], evidence_id: "other-evidence", source_label: "另一条监测来源" }] };
|
||||
render(<InvestigationReportBlocks task={expandedTask} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByText("保存的监测来源")).toBeVisible();
|
||||
expect(screen.getByText("引用依据:另一条监测来源")).toBeVisible();
|
||||
expect(screen.queryByText("引用依据:保存的监测来源、另一条监测来源")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps evidence references for tables without evidence cards", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].block.spec = { root: "table", elements: { table: {
|
||||
type: "Table", children: [], props: { title: "监测记录", columns: ["对象"], rows: [["北区"]] },
|
||||
} } };
|
||||
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByRole("table")).toHaveTextContent("北区");
|
||||
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
|
||||
});
|
||||
|
||||
it("turns report form input into a versioned conversation draft", async () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: {
|
||||
@@ -58,5 +75,9 @@ describe("formal report interactive content", () => {
|
||||
expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible();
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置");
|
||||
expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 });
|
||||
expect(onContinue.mock.calls[0][0].reportGeneration).toBe(2);
|
||||
expect(onContinue.mock.calls[0][0].reference.title).toBe("演练调查报告 · 报告第 3 版 · 补充资料");
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
|
||||
return <section className="investigation-report-section space-y-4" aria-label="交互分析">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">数据与分析</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">探索报告中的数据,选择内容后可引用到对话。</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">查看报告中的数据与依据;定位和补充资料使用各自入口。</p>
|
||||
</div>
|
||||
{blocks.map((item) => {
|
||||
const parsed = parseAnswerUiBlock(item.block);
|
||||
@@ -49,14 +49,17 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
|
||||
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
|
||||
}))
|
||||
} } : null;
|
||||
const shownEvidence = new Set(block ? Object.values(block.spec.elements)
|
||||
.filter((node) => node.type === "Evidence").map((node) => node.props.evidence_id) : []);
|
||||
const remainingRefs = item.evidence_refs.filter((id) => !shownEvidence.has(id));
|
||||
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
|
||||
{block ? <AnswerUiView block={block} onAsk={quote} submitMode="draft"
|
||||
{block ? <AnswerUiView block={block} submitMode="draft"
|
||||
onSubmit={(text) => quote({ title: "补充资料", text })}
|
||||
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
|
||||
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
引用依据:{evidence.map((entry) => entry.source_label).join("、") || "关联记录当前不可用"}
|
||||
</p>
|
||||
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
|
||||
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
|
||||
</p> : null}
|
||||
</div>;
|
||||
})}
|
||||
</section>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { InvestigationQuestions } from "./investigation-questions";
|
||||
import { History, LoaderCircle, MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
@@ -34,6 +35,7 @@ type Props = {
|
||||
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
|
||||
renderAction: (action: OperationalTaskAction) => ReactNode;
|
||||
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
|
||||
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (generation: number, revision: number) => void;
|
||||
focusActionId?: string | null;
|
||||
@@ -82,6 +84,12 @@ function investigationTimeline(
|
||||
detail: snapshot.report.current_conclusion
|
||||
});
|
||||
}
|
||||
task.questions?.forEach((question) => {
|
||||
items.push({ id: `question:${question.question_request_id}`, occurredAt: question.created_at,
|
||||
title: "调查提出补充问题", detail: question.questions.map((item) => item.title).join(";") });
|
||||
if (question.answered_at) items.push({ id: `answer:${question.question_request_id}`, occurredAt: question.answered_at,
|
||||
title: "已收到调查回答", detail: "回答已保存为用户补充信息,调查接续处理。" });
|
||||
});
|
||||
task.actions.forEach((action) => {
|
||||
const title = actionTitle(action);
|
||||
items.push({
|
||||
@@ -133,10 +141,14 @@ export function InvestigationTaskReport({
|
||||
renderAction,
|
||||
renderEvidence,
|
||||
onLoadReports,
|
||||
onAnswerQuestion,
|
||||
onReportViewed,
|
||||
focusActionId,
|
||||
onLocate
|
||||
}: Props) {
|
||||
const questionsRef = useRef<HTMLElement>(null);
|
||||
const hasPendingQuestions = task.questions?.some((request) => request.status === "pending"
|
||||
&& request.execution_generation === task.execution_generation) ?? false;
|
||||
// Null follows live task content. Only a deliberate history selection freezes a report.
|
||||
const [selection, setSelection] = useState<ReportSelection | null>(null);
|
||||
const [history, setHistory] = useState<OperationalTaskReport[]>([]);
|
||||
@@ -313,6 +325,8 @@ export function InvestigationTaskReport({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<InvestigationQuestions task={task} onAnswer={onAnswerQuestion} sectionRef={questionsRef} />
|
||||
|
||||
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
|
||||
|
||||
{latestAvailable ? (
|
||||
@@ -489,14 +503,19 @@ export function InvestigationTaskReport({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
discuss(
|
||||
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`,
|
||||
{ gap_id: gap.gap_id }
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (hasPendingQuestions) {
|
||||
questionsRef.current?.scrollIntoView({ block: "start" });
|
||||
questionsRef.current?.focus({ preventScroll: true });
|
||||
} else {
|
||||
discuss(
|
||||
`请帮我讨论“${formatInvestigationText(gap.missing_data)}”这个缺口的查询范围和补齐途径。`,
|
||||
{ gap_id: gap.gap_id }
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
补充事实
|
||||
{hasPendingQuestions ? "回答当前调查问题" : "讨论此缺口"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,12 +567,6 @@ export function InvestigationTaskReport({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
onAsk={(node) =>
|
||||
discuss(
|
||||
`请解释调查脉络中的“${node.label}”及其关联依据。`,
|
||||
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -50,6 +50,24 @@ describe("MapDevPanel", () => {
|
||||
expect(onFollowChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("keeps map inputs when switching between investigation and map tools", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
|
||||
detailFeature={null} onClose={vi.fn()} onInvestigationFollowChange={vi.fn()} />);
|
||||
expect(screen.getByRole("tab", { name: "调查测试" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByRole("button", { name: "定位并高亮" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "地图调试" }));
|
||||
const featureId = screen.getByLabelText("要素 ID(可多个)");
|
||||
await user.clear(featureId);
|
||||
await user.type(featureId, "DEMO-PIPE-03");
|
||||
await user.click(screen.getByRole("tab", { name: "调查测试" }));
|
||||
expect(screen.queryByRole("button", { name: "重置地图调试效果" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "地图调试" }));
|
||||
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("DEMO-PIPE-03");
|
||||
expect(commands.resetAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates a pipe with its signed velocity", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
|
||||
@@ -6,25 +6,27 @@ import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
type LayerGroupId,
|
||||
type LayerGroupStylePatch
|
||||
} from "../map/layer-group-style";
|
||||
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import { getNumericFeatureProperties } from "../map/value-label";
|
||||
import type {
|
||||
FeatureBatchTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
SUPPLY_LAYER_CATALOG,
|
||||
type AvailableWaterNetworkSourceId
|
||||
} from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
|
||||
|
||||
type MapDevPanelProps = {
|
||||
commands: WorkbenchMapCommands;
|
||||
@@ -34,6 +36,7 @@ type MapDevPanelProps = {
|
||||
onClose: () => void;
|
||||
investigationFollow?: boolean;
|
||||
onInvestigationFollowChange?: (enabled: boolean) => void;
|
||||
investigationTools?: InvestigationDevToolsProps;
|
||||
};
|
||||
|
||||
const GROUP_LABELS = Object.fromEntries(
|
||||
@@ -109,8 +112,10 @@ export function MapDevPanel({
|
||||
flowDataSource = "mock",
|
||||
onClose,
|
||||
investigationFollow = true,
|
||||
onInvestigationFollowChange
|
||||
onInvestigationFollowChange,
|
||||
investigationTools
|
||||
}: MapDevPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
|
||||
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
|
||||
const [featureId, setFeatureId] = useState("440681");
|
||||
const [property, setProperty] = useState("diameter");
|
||||
@@ -241,17 +246,17 @@ export function MapDevPanel({
|
||||
return (
|
||||
<aside
|
||||
aria-label="地图开发面板"
|
||||
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
data-testid="map-dev-panel"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
地图调试{" "}
|
||||
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span>
|
||||
开发测试{" "}
|
||||
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
按功能展开 · 设置仅本次页面会话生效
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
调查联动验证与地图工具 · 仅当前页面生效
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -265,429 +270,436 @@ export function MapDevPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 [scrollbar-gutter:stable]">
|
||||
{controllerState.errorCode ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="status-tone-danger my-3 break-words rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onInvestigationFollowChange ? (
|
||||
<PanelSection
|
||||
title="调查联动"
|
||||
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
|
||||
initiallyOpen
|
||||
status={investigationFollow ? "已开启" : "已关闭"}
|
||||
>
|
||||
<ToggleField
|
||||
label="跟随所选调查"
|
||||
checked={investigationFollow}
|
||||
onChange={onInvestigationFollowChange}
|
||||
/>
|
||||
</PanelSection>
|
||||
) : null}
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
onValueChange={(value) => {
|
||||
setSourceId(value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="例如:440681, 440682 440683"
|
||||
aria-describedby="map-dev-feature-ids-help"
|
||||
aria-invalid={invalidIds}
|
||||
className="resize-y text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
|
||||
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
|
||||
</p>
|
||||
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
|
||||
</StatusText>
|
||||
) : null}
|
||||
{controllerState.targetResult ? (
|
||||
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
|
||||
本次命中 {controllerState.targetResult.featureIds.length} 个
|
||||
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="水流调试"
|
||||
status={controllerState.flowVisible ? "已开启" : "未开启"}
|
||||
description={
|
||||
flowDataSource === "realtime"
|
||||
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
|
||||
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={controllerState.pending || controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(true)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
开启水流
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(false)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
关闭水流
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<Input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || featureIds.length !== 1}
|
||||
onClick={() => setFlowPipeId(featureIds[0])}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
|
||||
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
|
||||
{flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后显示演示流向</StatusText>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="图层样式"
|
||||
status={
|
||||
controllerState.styledGroups.length > 0
|
||||
? `${controllerState.styledGroups.length} 组已覆盖`
|
||||
: undefined
|
||||
}
|
||||
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
|
||||
>
|
||||
<FieldSelect
|
||||
label="业务图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField
|
||||
label="颜色"
|
||||
value={String(draft.color)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="宽度倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.widthMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, widthMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<FieldSelect
|
||||
label="线型"
|
||||
value={String(draft.dash)}
|
||||
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
|
||||
options={[
|
||||
{ value: "original", label: "原始" },
|
||||
{ value: "solid", label: "实线" },
|
||||
{ value: "dashed", label: "虚线" }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ColorField
|
||||
label="填充"
|
||||
value={String(draft.fillColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label="描边"
|
||||
value={String(draft.strokeColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
|
||||
/>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
|
||||
<TabsTrigger value="map">地图调试</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{onInvestigationFollowChange ? (
|
||||
<section aria-label="调查联动" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<ToggleField label="跟随所选调查" checked={investigationFollow} onChange={onInvestigationFollowChange} />
|
||||
<p className="text-xs leading-5 text-muted-foreground">选中调查后,跟随新的地图高亮、图表和影响范围指令。</p>
|
||||
</div>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
{investigationTools ? <InvestigationDevTools key={`${investigationTools.task?.task_id}:${investigationTools.task?.execution_generation}`} {...investigationTools} /> : null}
|
||||
</section>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{controllerState.errorCode ? (
|
||||
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
onValueChange={(value) => {
|
||||
setSourceId(value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<RangeField
|
||||
label="半径倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.radiusMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, radiusMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<RangeField
|
||||
label="描边倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.strokeMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, strokeMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="例如:440681, 440682 440683"
|
||||
aria-describedby="map-dev-feature-ids-help"
|
||||
aria-invalid={invalidIds}
|
||||
className="resize-y text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
|
||||
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
|
||||
</p>
|
||||
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
|
||||
</StatusText>
|
||||
) : null}
|
||||
{controllerState.targetResult ? (
|
||||
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
|
||||
本次命中 {controllerState.targetResult.featureIds.length} 个
|
||||
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="水流调试"
|
||||
status={controllerState.flowVisible ? "已开启" : "未开启"}
|
||||
description={
|
||||
flowDataSource === "realtime"
|
||||
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
|
||||
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={controllerState.pending || controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(true)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
开启水流
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(false)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
关闭水流
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<Input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || featureIds.length !== 1}
|
||||
onClick={() => setFlowPipeId(featureIds[0])}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
|
||||
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
|
||||
{flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后显示演示流向</StatusText>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="图层样式"
|
||||
status={
|
||||
controllerState.styledGroups.length > 0
|
||||
? `${controllerState.styledGroups.length} 组已覆盖`
|
||||
: undefined
|
||||
}
|
||||
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
|
||||
>
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="业务图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField
|
||||
label="颜色"
|
||||
value={String(draft.color)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="宽度倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.iconMultiplier)}
|
||||
value={Number(draft.widthMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, iconMultiplier: value }))
|
||||
setDraft((current) => ({ ...current, widthMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="数值标注"
|
||||
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
|
||||
status={
|
||||
hasActiveLabels
|
||||
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FieldSelect
|
||||
label="标注图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<FieldSelect
|
||||
label="数值属性"
|
||||
value={property}
|
||||
onValueChange={setProperty}
|
||||
options={LABEL_PRESETS[groupId].options}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="线型"
|
||||
value={String(draft.dash)}
|
||||
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
|
||||
options={[
|
||||
{ value: "original", label: "原始" },
|
||||
{ value: "solid", label: "实线" },
|
||||
{ value: "dashed", label: "虚线" }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ColorField
|
||||
label="填充"
|
||||
value={String(draft.fillColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label="描边"
|
||||
value={String(draft.strokeColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
|
||||
/>
|
||||
</div>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="半径倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.radiusMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, radiusMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<RangeField
|
||||
label="描边倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.strokeMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, strokeMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
value={precision}
|
||||
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||
className={inputClassName}
|
||||
step={0.1}
|
||||
value={Number(draft.iconMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, iconMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="单位">
|
||||
<Input
|
||||
maxLength={16}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
|
||||
<PanelSection
|
||||
title="数值标注"
|
||||
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
|
||||
status={
|
||||
hasActiveLabels
|
||||
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="标注图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="数值属性"
|
||||
value={property}
|
||||
onValueChange={setProperty}
|
||||
options={LABEL_PRESETS[groupId].options}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3}
|
||||
value={precision}
|
||||
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="单位">
|
||||
<Input
|
||||
maxLength={16}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="shrink-0 border-t border-border p-3" hidden={activeTab !== "map"}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetAll()}
|
||||
@@ -718,12 +730,12 @@ function PanelSection({
|
||||
return (
|
||||
<Disclosure
|
||||
initiallyOpen={initiallyOpen}
|
||||
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground"
|
||||
className="border-b border-border py-2 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground [&>summary]:text-sm"
|
||||
label={
|
||||
<span className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{status ? (
|
||||
<span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]">
|
||||
<span className="shrink-0 text-[11px] font-normal text-muted-foreground">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -731,7 +743,7 @@ function PanelSection({
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 px-2 pb-3 pt-1">
|
||||
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
</Disclosure>
|
||||
@@ -741,7 +753,7 @@ function PanelSection({
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span>
|
||||
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
@@ -757,7 +769,7 @@ function ToggleField({
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 py-2 text-xs font-medium text-foreground">
|
||||
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-2.5 py-2 text-xs font-medium text-foreground">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
@@ -778,7 +790,7 @@ function ColorField({
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
|
||||
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-input bg-background px-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
@@ -786,7 +798,7 @@ function ColorField({
|
||||
aria-label={label}
|
||||
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
|
||||
/>
|
||||
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span>
|
||||
<span className="text-xs font-medium tabular-nums text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
@@ -835,4 +847,4 @@ function StatusText({ children, tone = "success" }: { children: React.ReactNode;
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-foreground transition-colors";
|
||||
"h-10 w-full rounded-lg border border-input bg-background px-2.5 text-xs text-foreground transition-colors";
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
|
||||
type Props = {
|
||||
blocks: AnswerUiBlock[];
|
||||
records: Array<Record<string, unknown>>;
|
||||
reviewing: boolean;
|
||||
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function PresentationBusinessResults({
|
||||
blocks,
|
||||
records,
|
||||
reviewing,
|
||||
onLocateTargets
|
||||
}: Props) {
|
||||
if (!blocks.length && !records.length) return null;
|
||||
return (
|
||||
<section aria-label="展示结果" className="min-w-0 space-y-3">
|
||||
{blocks.map((block) => (
|
||||
<AnswerUiView
|
||||
key={block.block_id}
|
||||
block={block}
|
||||
disabled={reviewing}
|
||||
onLocate={
|
||||
onLocateTargets && !reviewing
|
||||
? (target) =>
|
||||
onLocateTargets(
|
||||
target.feature_ids.map((feature_id) => ({
|
||||
source_id: target.layer,
|
||||
feature_id
|
||||
}))
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!blocks.length && records.length ? (
|
||||
<Card className="border-border shadow-none">
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto px-3 pb-3 pt-0">
|
||||
<table className="w-full text-left text-[11px] leading-5">
|
||||
<caption className="sr-only">调查数据摘要</caption>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 pr-3 font-medium">类型</th>
|
||||
<th className="py-1 pr-3 font-medium">对象</th>
|
||||
<th className="py-1 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record, index) => (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
|
||||
<td className="break-all py-1.5 pr-3">
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="break-all py-1.5">{String(record.origin)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "@/test/radix-select-test-setup";
|
||||
import { PresentationViewHistory } from "../utils/presentation-view-history";
|
||||
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function entries() {
|
||||
const history = new PresentationViewHistory();
|
||||
history.record("压力曲线", "preview", null);
|
||||
history.update({ ...history.current, records: [{ element_id: "V1" }] });
|
||||
history.record("阀门列表", "preview", null);
|
||||
return history.entries;
|
||||
}
|
||||
|
||||
it("merges the latest result into current and skips its duplicate when going back", async () => {
|
||||
const user = userEvent.setup();
|
||||
const history = entries();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<PresentationHistorySwitcher
|
||||
entries={history}
|
||||
currentEntryId={history[1].id}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
expect(screen.getByRole("option", { name: /当前(最新).*阀门列表/ })).toBeVisible();
|
||||
expect(screen.getByRole("option", { name: /1\. 压力曲线/ })).toBeVisible();
|
||||
expect(screen.queryByRole("option", { name: /^2\. 阀门列表/ })).not.toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "上一个动作" }));
|
||||
expect(onSelect).toHaveBeenCalledWith(history[0].id);
|
||||
});
|
||||
|
||||
it("keeps the final historical result selectable when current has been cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
const history = entries();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<PresentationHistorySwitcher
|
||||
entries={history}
|
||||
currentEntryId={null}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
|
||||
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
|
||||
"当前(最新) · 2 个动作",
|
||||
"2. 阀门列表 · 完成",
|
||||
"1. 压力曲线 · 完成"
|
||||
]);
|
||||
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(history[1].id);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ChevronLeft, ChevronRight, Radio } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
|
||||
|
||||
const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" };
|
||||
|
||||
export function PresentationHistorySwitcher({
|
||||
entries,
|
||||
currentEntryId,
|
||||
selectedId,
|
||||
onSelect
|
||||
}: PresentationHistoryControls) {
|
||||
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
|
||||
const available = entries.filter(
|
||||
(entry) => entry.status !== "running" && entry.id !== currentEntryId
|
||||
);
|
||||
const index =
|
||||
selectedId === null
|
||||
? available.length
|
||||
: available.findIndex((entry) => entry.id === selectedId);
|
||||
return (
|
||||
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="上一个动作"
|
||||
disabled={index <= 0}
|
||||
onClick={() => onSelect(available[index - 1].id)}
|
||||
>
|
||||
<ChevronLeft size={14} aria-hidden />
|
||||
</Button>
|
||||
<Select
|
||||
value={selectedId === null ? "current" : String(selectedId)}
|
||||
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="切换展示动作"
|
||||
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
|
||||
>
|
||||
<SelectItem value="current" className="text-xs">
|
||||
当前(最新)
|
||||
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
|
||||
{entries.length} 个动作
|
||||
</SelectItem>
|
||||
{entries
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, position) =>
|
||||
entry.id === currentEntryId ? null : (
|
||||
<SelectItem
|
||||
key={entry.id}
|
||||
value={String(entry.id)}
|
||||
disabled={entry.status === "running"}
|
||||
className="whitespace-normal text-xs [&>span:last-child]:break-words"
|
||||
>
|
||||
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="下一个动作"
|
||||
disabled={selectedId === null}
|
||||
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
</Button>
|
||||
{selectedId !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<Radio size={12} aria-hidden />
|
||||
回到当前
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
} from "../api/operational-task-client";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { formatDateTime } from "@/shared/utils/date-time";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
@@ -115,6 +114,7 @@ type ConditionDetailPanelProps = {
|
||||
workOrderDataSource?: WorkbenchWorkOrderDataSource;
|
||||
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
|
||||
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
|
||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -171,6 +171,7 @@ export function ConditionDetailPanel({
|
||||
onContinueConversation,
|
||||
onViewTaskExecutionHistory,
|
||||
onTaskDecision,
|
||||
onAnswerTaskQuestion,
|
||||
onTaskExecute,
|
||||
onTaskDraftUpdate,
|
||||
onTaskConfirmDispatch,
|
||||
@@ -200,6 +201,7 @@ export function ConditionDetailPanel({
|
||||
onContinueConversation={onContinueConversation}
|
||||
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
|
||||
onTaskDecision={onTaskDecision}
|
||||
onAnswerTaskQuestion={onAnswerTaskQuestion}
|
||||
onTaskExecute={onTaskExecute}
|
||||
onTaskDraftUpdate={onTaskDraftUpdate}
|
||||
onTaskConfirmDispatch={onTaskConfirmDispatch}
|
||||
@@ -237,6 +239,7 @@ function ConditionRecordDetailPanel({
|
||||
onContinueConversation,
|
||||
onViewTaskExecutionHistory,
|
||||
onTaskDecision,
|
||||
onAnswerTaskQuestion,
|
||||
onTaskExecute,
|
||||
onTaskDraftUpdate,
|
||||
onTaskConfirmDispatch,
|
||||
@@ -255,6 +258,7 @@ function ConditionRecordDetailPanel({
|
||||
onContinueConversation: ConditionDetailPanelProps["onContinueConversation"];
|
||||
onViewTaskExecutionHistory?: ConditionDetailPanelProps["onViewTaskExecutionHistory"];
|
||||
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
|
||||
onAnswerTaskQuestion?: ConditionDetailPanelProps["onAnswerTaskQuestion"];
|
||||
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
|
||||
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
|
||||
onTaskConfirmDispatch?: ConditionDetailPanelProps["onTaskConfirmDispatch"];
|
||||
@@ -322,6 +326,7 @@ function ConditionRecordDetailPanel({
|
||||
<AgentExecutionPlanPanel
|
||||
onLocate={onLocate}
|
||||
task={condition.operationalTask}
|
||||
onAnswerQuestion={onAnswerTaskQuestion ? (...args) => onAnswerTaskQuestion(condition.operationalTask!.task_id, ...args) : undefined}
|
||||
onLoadReports={onLoadTaskReports ? (offset) => onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined}
|
||||
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
|
||||
focusActionId={focusActionId}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
X,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
|
||||
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
|
||||
@@ -87,6 +86,7 @@ type ScheduledConditionFeedProps = {
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
|
||||
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void>;
|
||||
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
|
||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -129,6 +129,7 @@ export function ScheduledConditionFeed({
|
||||
onOpenTaskCollaboration,
|
||||
onViewTaskExecutionHistory,
|
||||
onTaskDecision,
|
||||
onAnswerTaskQuestion,
|
||||
onTaskExecute,
|
||||
onTaskDraftUpdate,
|
||||
onTaskConfirmDispatch,
|
||||
@@ -211,7 +212,6 @@ export function ScheduledConditionFeed({
|
||||
: [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions, taskCategory]
|
||||
);
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
|
||||
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
|
||||
const headerSummary = loading && backendLoading
|
||||
@@ -559,6 +559,7 @@ export function ScheduledConditionFeed({
|
||||
onContinueConversation={(discussion) => handleContinueConversation(selectedCondition, discussion)}
|
||||
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
|
||||
onTaskDecision={onTaskDecision}
|
||||
onAnswerTaskQuestion={onAnswerTaskQuestion}
|
||||
onTaskExecute={onTaskExecute}
|
||||
onTaskDraftUpdate={onTaskDraftUpdate}
|
||||
onTaskConfirmDispatch={onTaskConfirmDispatch}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client";
|
||||
import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel";
|
||||
import { investigationStatus } from "../utils/investigation-presentation";
|
||||
|
||||
type TaskConversationReviewProps = {
|
||||
task: OperationalTask;
|
||||
onAccessDecision: (requestId: string, version: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
|
||||
onActionDecision: (actionId: string, version: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onActionExecute: (actionId: string, version: number) => Promise<unknown>;
|
||||
onDraftUpdate: (actionId: string, version: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
onConfirmDispatch: (actionId: string, version: number) => Promise<unknown>;
|
||||
onActionReconcile: (actionId: string, version: number) => Promise<unknown>;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute,
|
||||
onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) {
|
||||
const pendingAccess = (task.task_type === "event_investigation" ? [] : task.access_requests).filter((request) => request.status === "pending");
|
||||
const pendingActions = task.actions.filter((action) => action.approval_status === "pending"
|
||||
|| action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status));
|
||||
const recentFacts = task.evidence.filter((item) => item.kind === "human_input")
|
||||
.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, 3);
|
||||
return <section aria-label="任务协作状态" className="surface-reading space-y-2 rounded-lg border border-[var(--surface-divider)] p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-[var(--ink)]"><ShieldCheck size={14} aria-hidden="true" />{task.title}</div>
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">当前第 {task.execution_generation} 轮 · {investigationStatus(task)}</p>
|
||||
{recentFacts.length > 0 ? <details>
|
||||
<summary className="cursor-pointer py-1 text-xs text-[var(--ink-muted)]">最近已记录的人工事实 · {recentFacts.length} 条</summary>
|
||||
<ul className="mt-1 space-y-2 text-xs leading-5 text-[var(--ink)]">{recentFacts.map((item) => <li key={item.evidence_id}>
|
||||
<p>{item.summary}</p><p className="text-[var(--ink-muted)]">{item.source_label ?? "人工输入"} · 记录于 {new Date(item.recorded_at).toLocaleString("zh-CN", { hour12: false })}</p>
|
||||
{item.observed_at ? <p className="text-[var(--ink-muted)]">现场时间 {new Date(item.observed_at).toLocaleString("zh-CN", { hour12: false })}</p> : null}
|
||||
</li>)}</ul>
|
||||
</details> : null}
|
||||
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
|
||||
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
|
||||
canDecide={task.permissions.can_decide_access}
|
||||
onDecision={(requestId, version, decision, scopes) => onAccessDecision(requestId, version, decision, scopes)}
|
||||
onRefresh={onRefresh} />)}
|
||||
{pendingActions.map((action) => <HumanActionCard key={action.action_id} task={task} action={action}
|
||||
onDecision={onActionDecision} onExecute={onActionExecute} onDraftUpdate={onDraftUpdate}
|
||||
onConfirmDispatch={onConfirmDispatch} onReconcile={onActionReconcile} onRefresh={onRefresh} />)}
|
||||
</section>;
|
||||
}
|
||||
@@ -52,26 +52,27 @@ export function WorkbenchSection({
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
data-slot="workbench-section"
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[14px] border border-[var(--surface-divider)]/90 bg-[var(--surface-reading)] shadow-[0_1px_2px_rgba(15,23,42,0.06)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
|
||||
<div data-slot="workbench-section-header" className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
|
||||
{icon ? (
|
||||
<span className={cn("mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg", sectionTone[tone])}>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div data-slot="workbench-section-heading" className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3 className="text-[13px] font-semibold leading-5 text-slate-900">{title}</h3>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? <p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
|
||||
{actions ? <div data-slot="workbench-section-actions" className="flex shrink-0 items-center gap-1">{actions}</div> : null}
|
||||
</div>
|
||||
<div className={cn("p-3.5", bodyClassName)}>{children}</div>
|
||||
</section>
|
||||
@@ -93,6 +94,8 @@ export function WorkbenchNotice({
|
||||
const Icon = noticeIcon[tone];
|
||||
return (
|
||||
<div
|
||||
data-slot="workbench-notice"
|
||||
data-tone={tone}
|
||||
className={cn(
|
||||
"flex min-w-0 gap-2.5 rounded-r-lg border-l-2 px-3 py-2.5 text-xs leading-5",
|
||||
noticeTone[tone],
|
||||
|
||||
Reference in New Issue
Block a user