317 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|