Files
next-tjwater-frontend/src/features/workbench/components/investigation-dev-tools.tsx
T

317 lines
12 KiB
TypeScript

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 {
investigationReportJsonl,
investigationReportUiSummary,
parseInvestigationDevJsonl,
type InvestigationDevPreview
} from "../utils/investigation-dev-preview";
export type InvestigationDevToolsProps = {
task: OperationalTask | null;
mapReady: boolean;
charts: number;
overlay: string | null;
onSample: (caseId?: InvestigationDevCaseId) => string;
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
onClear: () => void;
};
export function InvestigationDevTools({
task,
mapReady,
charts,
overlay,
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 [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
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);
setMessage(null);
setError(null);
}
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);
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">
仅在当前浏览器预览,不写入任务或触发调查。空间位置与要素 ID 以 tjwater_v2 为基准;读数与方案为合成样例,不代表现场事实。
</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">
切换即载入,执行时自动校验;已执行的本地预览会清除。定位和高亮请使用“地图调试”页签的业务源与要素 ID。
</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;
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}
</div>
) : null}
</div>
);
}