feat: add project-aware agent workspace
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
|
||||
@@ -22,7 +22,7 @@ export function AnalysisHistorySection({
|
||||
getAccessToken,
|
||||
onOpenArtifact
|
||||
}: {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onOpenArtifact: (artifact: AnalysisArtifact) => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<WorkbenchAnalysisHistoryItem[]>([]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BarChart3, X } from "lucide-react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { AnalysisArtifact } from "../workspace/workspace-model";
|
||||
import { AnalysisHistorySection } from "./analysis-history-section";
|
||||
@@ -12,7 +12,7 @@ export function AnalysisResultsPanel({
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onOpenArtifact: (artifact: AnalysisArtifact) => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Check, Copy, Database, X } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
@@ -36,7 +36,7 @@ import { FeaturePropertyList } from "./feature-property-list";
|
||||
type FeaturePopoverProps = {
|
||||
feature: DetailFeature | null;
|
||||
projectId: string | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
|
||||
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
|
||||
import { useId, useLayoutEffect, useRef } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
@@ -31,8 +31,9 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
const candidateListRef = useRef<HTMLUListElement | null>(null);
|
||||
const changeSelectionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const { address, result, results, pending, error, enabled } = geocoding;
|
||||
const mapResult = geocoding.origin === "map";
|
||||
const selectionCollapsed = Boolean(result && !geocoding.choosing);
|
||||
const hideSelectionHint = Boolean(results?.items.length && enabled && !error);
|
||||
const hideSelectionHint = Boolean(results?.items.length && enabled && !error && !geocoding.picking);
|
||||
useLayoutEffect(() => {
|
||||
if (selectionCollapsed || !result) return;
|
||||
const list = candidateListRef.current;
|
||||
@@ -42,12 +43,13 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
const row = selected.getBoundingClientRect();
|
||||
list.scrollTop += row.top - viewport.top - (list.clientHeight - row.height) / 2;
|
||||
}, [selectionCollapsed, result, results]);
|
||||
const status = error
|
||||
?? (pending ? "正在搜索地名…"
|
||||
const status = !geocoding.configured ? "当前项目暂未配置地名搜索" : error
|
||||
?? (pending ? (mapResult ? "正在查询取点位置…" : "正在搜索地名…")
|
||||
: !enabled ? "等待地图加载后即可定位"
|
||||
: geocoding.picking ? "点击地图查询地名,按 Esc 结束取点"
|
||||
: result ? "已在地图标出所选位置"
|
||||
: results ? results.items.length ? "选择一个候选位置进行定位" : "奉贤区内未找到匹配位置,请细化关键字"
|
||||
: "搜索奉贤区内地址或地名,支持回车查询");
|
||||
: results ? results.items.length ? "选择一个候选位置进行定位" : `${geocoding.region}内未找到匹配位置,请细化关键字`
|
||||
: `搜索${geocoding.region}内地址或地名,支持回车查询`);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
@@ -79,12 +81,17 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
aria-label="搜索地名"
|
||||
className="shrink-0 gap-1.5 px-3 text-xs"
|
||||
disabled={!enabled || !address.trim()}
|
||||
loading={pending}
|
||||
loading={pending && !mapResult}
|
||||
loadingLabel="正在搜索地名"
|
||||
>
|
||||
<Search aria-hidden="true" />查询
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" className="w-full text-xs"
|
||||
aria-label="从地图取点查找地名" aria-pressed={geocoding.picking} disabled={!enabled}
|
||||
onClick={geocoding.togglePicking}>
|
||||
<Crosshair aria-hidden="true" />{geocoding.picking ? "结束取点" : "地图取点"}
|
||||
</Button>
|
||||
<div
|
||||
id={statusId}
|
||||
role={error ? "alert" : "status"}
|
||||
@@ -97,9 +104,9 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
</form>
|
||||
{results && results.items.length > 0 ? (
|
||||
<section aria-label="搜索结果" className="flex min-h-0 flex-1 flex-col gap-2 border-t border-[var(--surface-divider)] pt-3">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]">
|
||||
<span className="text-xs font-semibold">{selectionCollapsed ? "已选位置" : "选择位置"}</span>
|
||||
{selectionCollapsed ? (
|
||||
<div className={cn("flex shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]", mapResult ? "h-6" : "h-10")}>
|
||||
<span className="text-xs font-semibold">{selectionCollapsed ? mapResult ? "取点结果" : "已选位置" : "选择位置"}</span>
|
||||
{selectionCollapsed && !mapResult ? (
|
||||
<Button ref={changeSelectionRef} type="button" variant="secondary" className="gap-1.5 px-3 text-xs" aria-expanded={false}
|
||||
onClick={(event) => {
|
||||
geocoding.changeSelection();
|
||||
@@ -111,7 +118,7 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
}}>
|
||||
更换位置<ChevronDown aria-hidden="true" />
|
||||
</Button>
|
||||
) : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
|
||||
) : mapResult ? <span>地图位置</span> : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
|
||||
</div>
|
||||
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
|
||||
{results.items.map((item, index) => (
|
||||
@@ -132,8 +139,8 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
{result && selectionCollapsed ? (
|
||||
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-h-16 items-center gap-3 border-b border-[var(--surface-divider)] px-1 py-3 text-xs">
|
||||
<div className={mapResult ? "space-y-2" : "space-y-3"}>
|
||||
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
|
||||
<PlaceSummary item={result} annotation={result.category ?? result.district} />
|
||||
</div>
|
||||
<dl aria-label="经纬度坐标" className="space-y-1 px-1">
|
||||
|
||||
@@ -55,7 +55,7 @@ export function InvestigationDiagramView({
|
||||
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
|
||||
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
|
||||
running?: boolean;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cn } from "@/shared/ui/cn";
|
||||
/** Report-specific progressive disclosure. The source stays mounted while reading. */
|
||||
export function InvestigationExpandCard({
|
||||
title, label = title, preview, children, wide = false, onOpen, contentRef,
|
||||
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false
|
||||
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false, headingAccessory
|
||||
}: {
|
||||
title: string;
|
||||
label?: string;
|
||||
@@ -25,6 +25,7 @@ export function InvestigationExpandCard({
|
||||
disabled?: boolean;
|
||||
/** Move one live content host between slots, preserving expensive renderers. */
|
||||
reuseContent?: boolean;
|
||||
headingAccessory?: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -77,7 +78,10 @@ export function InvestigationExpandCard({
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<div className="investigation-card-heading">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
|
||||
<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>
|
||||
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
|
||||
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } 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";
|
||||
|
||||
const now = "2026-10-20T08:00:00Z";
|
||||
const task: Pick<OperationalTask, "evidence"> = { evidence: [{
|
||||
evidence_id: "saved-evidence", kind: "synthetic", summary: "保存的演练压力,不代表现场事实。",
|
||||
source_label: "保存的监测来源", observed_at: now, recorded_at: now, recorded_by: "agent", source_ref: null, has_snapshot: false,
|
||||
}] };
|
||||
const snapshot: OperationalTaskReport = {
|
||||
revision: 3, execution_generation: 2, source_session_id: "session", published_at: now, published_by: "agent",
|
||||
report: { title: "演练调查报告", current_conclusion: "待核验", conclusion_boundary: "演练数据",
|
||||
evidence_refs: ["saved-evidence"], recommendations: [], pending_items: [], ui_blocks: [{
|
||||
evidence_refs: ["saved-evidence"], block: { version: "answer-ui@1", block_id: "analysis", fallback_text: "分析待核验。",
|
||||
spec: { root: "evidence", elements: { evidence: { type: "Evidence", children: [], props: {
|
||||
title: "监测依据", summary: "模型填写的错误摘要", source: "模型填写的错误来源", kind: "observed",
|
||||
observed_at: null, evidence_id: "saved-evidence",
|
||||
} } } },
|
||||
},
|
||||
}] },
|
||||
};
|
||||
|
||||
describe("formal report interactive content", () => {
|
||||
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => {
|
||||
const onContinue = vi.fn();
|
||||
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
|
||||
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)");
|
||||
});
|
||||
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].block.version = "answer-ui@future";
|
||||
next.report.ui_blocks![0].block.spec = { type: "iframe", src: "https://example.invalid" };
|
||||
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByText("分析待核验。")).toBeVisible();
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
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: {
|
||||
type: "FollowUpForm", children: [], props: { title: "补充资料", description: "填写核验位置",
|
||||
fields: [{ name: "site", label: "位置", kind: "text", required: true }] },
|
||||
} } };
|
||||
const onContinue = vi.fn();
|
||||
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={onContinue} />);
|
||||
fireEvent.change(screen.getByLabelText("位置(必填)"), { target: { value: "演练位置" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "带入对话" }));
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent";
|
||||
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
|
||||
import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
|
||||
|
||||
export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: {
|
||||
task: Pick<OperationalTask, "evidence">;
|
||||
snapshot: OperationalTaskReport;
|
||||
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
}) {
|
||||
const blocks = snapshot.report.ui_blocks ?? [];
|
||||
if (!blocks.length) return null;
|
||||
const ask = async (selection: AnswerUiSelection) => {
|
||||
const result = await onContinue({
|
||||
reference: {
|
||||
title: `${snapshot.report.title} · 报告第 ${snapshot.revision} 版 · ${selection.title}`,
|
||||
text: `${selection.text}\n报告第 ${snapshot.revision} 版${snapshot.execution_generation ? ` · 第 ${snapshot.execution_generation} 轮` : ""}`
|
||||
},
|
||||
context: { report_revision: snapshot.revision },
|
||||
reportGeneration: snapshot.execution_generation
|
||||
});
|
||||
if (result === false) throw new Error("引用暂未带入对话,请重试。");
|
||||
};
|
||||
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>
|
||||
</div>
|
||||
{blocks.map((item) => {
|
||||
const parsed = parseAnswerUiBlock(item.block);
|
||||
const evidence = task.evidence.filter((entry) => item.evidence_refs.includes(entry.evidence_id));
|
||||
const quote = (selection: AnswerUiSelection) => ask({
|
||||
...selection,
|
||||
text: `${selection.text}\n引用依据:${item.evidence_refs.map((id) => {
|
||||
const record = evidence.find((entry) => entry.evidence_id === id);
|
||||
return record ? `${record.source_label}(${id})` : `${id}(记录当前不可用)`;
|
||||
}).join("、")}`
|
||||
});
|
||||
// Evidence facts are supplied by the saved task record, not generated presentation props.
|
||||
const block: AnswerUiBlock | null = parsed ? { ...parsed, spec: {
|
||||
...parsed.spec,
|
||||
elements: Object.fromEntries(Object.entries(parsed.spec.elements).map(([id, node]) => {
|
||||
if (node.type !== "Evidence") return [id, node];
|
||||
const record = evidence.find((entry) => entry.evidence_id === node.props.evidence_id);
|
||||
return [id, record ? { ...node, props: {
|
||||
...node.props, summary: record.summary, source: record.source_label,
|
||||
observed_at: record.observed_at,
|
||||
kind: record.kind === "synthetic" ? "demo" : record.kind === "human_input" ? "human_input" : "observed"
|
||||
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
|
||||
}))
|
||||
} } : null;
|
||||
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
|
||||
{block ? <AnswerUiView block={block} onAsk={quote} 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>
|
||||
</div>;
|
||||
})}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
|
||||
|
||||
const labels = {
|
||||
任务报告: "结论",
|
||||
交互分析: "分析",
|
||||
当前调查进展: "调查进展",
|
||||
当前需处理: "待办",
|
||||
处置进展: "处置",
|
||||
数据缺口: "缺口",
|
||||
调查脉络: "脉络",
|
||||
主要依据: "依据",
|
||||
任务时间线: "时间线",
|
||||
报告版本: "版本",
|
||||
};
|
||||
export type InvestigationReportSection = keyof typeof labels;
|
||||
|
||||
export function InvestigationReportNavigation({ reportRef, sections }: {
|
||||
reportRef: RefObject<HTMLElement | null>;
|
||||
sections: InvestigationReportSection[];
|
||||
}) {
|
||||
const navigation = useRef<HTMLElement>(null);
|
||||
const lastJump = useRef<{ section: string; scrollTop: number } | null>(null);
|
||||
const reading = useReadingWorkspace();
|
||||
const [current, setCurrent] = useState<string>("任务报告");
|
||||
const sectionKey = sections.join("|");
|
||||
|
||||
useEffect(() => {
|
||||
const report = reportRef.current;
|
||||
const nav = navigation.current;
|
||||
const scroll = report?.closest<HTMLElement>("[data-reading-scroll]");
|
||||
if (!report || !nav || !scroll) return;
|
||||
const targets = sectionKey.split("|").flatMap((label) => {
|
||||
const element = report.querySelector<HTMLElement>(`section[aria-label="${label}"]`);
|
||||
return element ? [{ label, element }] : [];
|
||||
});
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
// Near the document end an anchor cannot always reach the top. Keep the
|
||||
// chosen chapter current until the reader actually moves away from it.
|
||||
if (lastJump.current && Math.abs(lastJump.current.scrollTop - scroll.scrollTop) < 1
|
||||
&& targets.some(({ label }) => label === lastJump.current?.section)) {
|
||||
setCurrent(lastJump.current.section);
|
||||
return;
|
||||
}
|
||||
lastJump.current = null;
|
||||
const edge = scroll.getBoundingClientRect().top + nav.offsetHeight + 20;
|
||||
const active = [...targets].reverse().find(({ element }) => element.getBoundingClientRect().top <= edge);
|
||||
setCurrent(active?.label ?? targets[0]?.label ?? "");
|
||||
};
|
||||
const schedule = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(update);
|
||||
};
|
||||
scroll.addEventListener("scroll", schedule, { passive: true });
|
||||
const observer = new ResizeObserver(schedule);
|
||||
observer.observe(report);
|
||||
observer.observe(scroll);
|
||||
schedule();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
scroll.removeEventListener("scroll", schedule);
|
||||
};
|
||||
}, [reportRef, sectionKey, reading?.active, reading?.view]);
|
||||
|
||||
// Keep the active chapter reachable when the navigation overflows on a phone.
|
||||
useEffect(() => {
|
||||
const nav = navigation.current;
|
||||
const button = nav?.querySelector<HTMLElement>('[aria-current="location"]');
|
||||
if (!nav || !button) return;
|
||||
const bounds = nav.getBoundingClientRect();
|
||||
const target = button.getBoundingClientRect();
|
||||
if (target.left < bounds.left) nav.scrollLeft -= bounds.left - target.left + 4;
|
||||
else if (target.right > bounds.right) nav.scrollLeft += target.right - bounds.right + 4;
|
||||
}, [current]);
|
||||
|
||||
return <nav ref={navigation} className="investigation-report-navigation" aria-label="报告章节">
|
||||
{sections.map((section) => <Button key={section} type="button" variant="ghost" size="sm"
|
||||
aria-current={current === section ? "location" : undefined}
|
||||
className="shrink-0 font-normal"
|
||||
onClick={() => {
|
||||
const target = reportRef.current?.querySelector<HTMLElement>(`section[aria-label="${section}"]`);
|
||||
if (!target) return;
|
||||
target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
const scroll = target.closest<HTMLElement>("[data-reading-scroll]");
|
||||
if (scroll) lastJump.current = { section, scrollTop: scroll.scrollTop };
|
||||
setCurrent(section);
|
||||
}}>
|
||||
{labels[section]}
|
||||
</Button>)}
|
||||
</nav>;
|
||||
}
|
||||
@@ -21,17 +21,20 @@ import {
|
||||
import { InvestigationDiagramView } from "./investigation-diagram-view";
|
||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||
import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
|
||||
import { ExpandableReading } from "@/shared/ui/reading-workspace";
|
||||
import { InvestigationReportBlocks } from "./investigation-report-blocks";
|
||||
import { InvestigationReportNavigation, type InvestigationReportSection } from "./investigation-report-navigation";
|
||||
|
||||
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
|
||||
type Props = {
|
||||
task: OperationalTask;
|
||||
onContinue: (discussion?: InvestigationDiscussion) => void;
|
||||
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
renderAction: (action: OperationalTaskAction) => ReactNode;
|
||||
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (generation: number, revision: number) => void;
|
||||
focusActionId?: string | null;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
};
|
||||
|
||||
const roundLabel = (snapshot: OperationalTaskReport) =>
|
||||
@@ -167,6 +170,17 @@ export function InvestigationTaskReport({
|
||||
const timeline = investigationTimeline(task, snapshot);
|
||||
const reportRef = useReportUpdateMotion(task.task_id,
|
||||
`${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live);
|
||||
const sections: InvestigationReportSection[] = [
|
||||
"任务报告",
|
||||
...(report?.ui_blocks?.length ? ["交互分析" as const] : []),
|
||||
...(live ? ["当前调查进展" as const] : []),
|
||||
...(pendingActions.length ? ["当前需处理" as const] : []),
|
||||
...(completedActions.length ? ["处置进展" as const] : []),
|
||||
...(report?.data_gaps?.length ? ["数据缺口" as const] : []),
|
||||
...(diagram ? ["调查脉络" as const] : []),
|
||||
"主要依据", "任务时间线",
|
||||
...(onLoadReports ? ["报告版本" as const] : []),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot?.execution_generation == null || !onReportViewed) return;
|
||||
@@ -209,6 +223,8 @@ export function InvestigationTaskReport({
|
||||
};
|
||||
|
||||
return (
|
||||
<ExpandableReading title={snapshot?.report.title ?? task.title}
|
||||
summary={<p>{snapshot?.report.current_conclusion ?? task.summary}</p>}>
|
||||
<article ref={reportRef} aria-label="调查任务报告" className="investigation-report surface-reading">
|
||||
<header className="investigation-report-header">
|
||||
<div className="min-w-0">
|
||||
@@ -244,6 +260,8 @@ export function InvestigationTaskReport({
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
|
||||
|
||||
{latestAvailable ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>正在阅读历史报告{olderRevision ? ",已有新报告" : ""}</span>
|
||||
@@ -254,9 +272,8 @@ export function InvestigationTaskReport({
|
||||
) : null}
|
||||
|
||||
<InvestigationExpandCard title="当前结果" label="任务报告" disabled={!report}
|
||||
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`} preview={<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{report ? (
|
||||
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`}
|
||||
headingAccessory={report ? (
|
||||
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
|
||||
{insufficient
|
||||
? "证据不足"
|
||||
@@ -265,9 +282,10 @@ export function InvestigationTaskReport({
|
||||
: "结果未记录"}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
preview={<>
|
||||
{report && snapshot ? (
|
||||
<>
|
||||
{previousRound || olderRevision || insufficient || report.stage !== "final" || report.outcome !== "concluded" ? (
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--ink)]">
|
||||
{previousRound
|
||||
? task.status === "queued" || task.status === "running"
|
||||
@@ -283,11 +301,10 @@ export function InvestigationTaskReport({
|
||||
: "无法形成结论 · 正在结束本轮"
|
||||
: report.stage === "interim"
|
||||
? "阶段性判断"
|
||||
: report.outcome === "concluded"
|
||||
? "调查结论"
|
||||
: "历史报告内容"}
|
||||
: "历史报告内容"}
|
||||
</p>
|
||||
<p className="investigation-conclusion-preview mt-1 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
|
||||
) : null}
|
||||
<p className="investigation-conclusion-preview mt-2 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
结论边界:{formatInvestigationText(report.conclusion_boundary)}
|
||||
</p>
|
||||
@@ -318,6 +335,8 @@ export function InvestigationTaskReport({
|
||||
</> : <p className="text-sm text-[var(--ink-muted)]">尚未发布本轮调查报告。</p>}
|
||||
</InvestigationExpandCard>
|
||||
|
||||
{snapshot ? <InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} onLocate={onLocate} /> : null}
|
||||
|
||||
{live ? <section aria-label="当前调查进展" className="investigation-report-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">当前调查进展</h3>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}</p>
|
||||
@@ -622,6 +641,7 @@ export function InvestigationTaskReport({
|
||||
任务更新于 {dateLabel(task.updated_at)}
|
||||
</p>
|
||||
</article>
|
||||
</ExpandableReading>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type MobileWorkbenchSheetProps = {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
suspended?: boolean;
|
||||
};
|
||||
|
||||
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
|
||||
@@ -31,7 +32,8 @@ export function MobileWorkbenchSheet({
|
||||
onSnapChange,
|
||||
onClose,
|
||||
children,
|
||||
className
|
||||
className,
|
||||
suspended = false
|
||||
}: MobileWorkbenchSheetProps) {
|
||||
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
@@ -52,6 +54,7 @@ export function MobileWorkbenchSheet({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (suspended) return;
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||
onClose();
|
||||
@@ -60,7 +63,7 @@ export function MobileWorkbenchSheet({
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
}, [onClose, suspended]);
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||
pointerStartRef.current = {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Droplet } from "lucide-react";
|
||||
import { StartupScreen } from "@/shared/ui/startup-screen";
|
||||
|
||||
export type ProjectStartupState = { phase: "projects" } | { phase: "context"; projectName: string };
|
||||
|
||||
export function ProjectStartupScreen({ state, error, onRetry }: {
|
||||
state: ProjectStartupState;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const readingProjects = state.phase === "projects";
|
||||
|
||||
return (
|
||||
<StartupScreen
|
||||
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
|
||||
title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"}
|
||||
description={error ? "请查看原因后重试。" : "正在准备你的项目工作台,完成后自动进入。"}
|
||||
progressLabel="项目加载进度"
|
||||
steps={[
|
||||
{ id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete",
|
||||
detail: readingProjects ? "获取当前账号可访问的项目列表" : "已获取可访问的项目" },
|
||||
{ id: "context", label: "准备项目环境", state: readingProjects ? "waiting" : "loading",
|
||||
detail: state.phase === "context" ? `${state.projectName}\n正在读取项目配置与访问权限` : "等待项目列表返回" }
|
||||
]}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { motion, useIsPresent, useReducedMotion } from "motion/react";
|
||||
import { Droplet, RefreshCw, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogDescription, DialogPortal, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import type { WorkbenchProjectSelection } from "./workbench-top-bar-menus";
|
||||
|
||||
const roleLabels: Record<string, string> = { member: "项目成员", admin: "项目管理员", viewer: "只读成员", owner: "项目负责人" };
|
||||
|
||||
export function ProjectSwitchDialog({ selection, keyboardOpened, onClose, onRestoreFocus }: {
|
||||
selection: WorkbenchProjectSelection;
|
||||
keyboardOpened: boolean;
|
||||
onClose: () => void;
|
||||
onRestoreFocus: () => void;
|
||||
}) {
|
||||
const [projectId, setProjectId] = useState(selection.selectedProjectId);
|
||||
const [attemptedProjectId, setAttemptedProjectId] = useState<string | null>(null);
|
||||
const [keyboard, setKeyboard] = useState(keyboardOpened);
|
||||
const present = useIsPresent();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const labelId = useId();
|
||||
const projectTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pending = selection.pendingProjectId !== null;
|
||||
const current = selection.projects.find((project) => project.project_id === selection.selectedProjectId);
|
||||
const selected = selection.projects.find((project) => project.project_id === projectId);
|
||||
const error = attemptedProjectId === projectId || attemptedProjectId === null ? selection.error : null;
|
||||
const transition = { duration: reducedMotion || keyboard ? 0 : 0.18, ease: [0.22, 1, 0.36, 1] as const };
|
||||
|
||||
return (
|
||||
<Dialog open={present} onOpenChange={(open) => { if (!open && !pending) onClose(); }}>
|
||||
<DialogPortal forceMount>
|
||||
<DialogPrimitive.Overlay forceMount asChild>
|
||||
<motion.div className="fixed inset-0 z-[100] bg-slate-950/10 backdrop-blur-[3px]"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
|
||||
</DialogPrimitive.Overlay>
|
||||
<DialogPrimitive.Content forceMount asChild
|
||||
onOpenAutoFocus={(event) => { event.preventDefault(); projectTriggerRef.current?.focus({ preventScroll: true }); }}
|
||||
onEscapeKeyDown={() => setKeyboard(true)}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); onRestoreFocus(); }}>
|
||||
<motion.section
|
||||
className="acrylic-panel acrylic-panel-white fixed left-1/2 top-1/2 z-[101] max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[420px] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-border)] p-6 text-slate-900 shadow-[var(--shadow-panel)] outline-0 sm:p-7"
|
||||
initial={reducedMotion || keyboard ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reducedMotion || keyboard ? 0 : 4 }} transition={transition}
|
||||
onKeyDownCapture={() => setKeyboard(true)} onPointerDownCapture={() => setKeyboard(false)}>
|
||||
{selection.onRefresh ? <Button className="absolute right-14 top-3" variant="ghost" size="icon-sm"
|
||||
aria-label="刷新授权项目" loading={selection.refreshing} onClick={selection.onRefresh}><RefreshCw size={16} /></Button> : null}
|
||||
<Button className="absolute right-3 top-3" variant="ghost" size="icon-sm" aria-label="关闭项目切换" disabled={pending} onClick={onClose}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</Button>
|
||||
<header>
|
||||
<div className="flex items-center gap-2 pr-7 text-xs font-medium text-slate-500">
|
||||
<Droplet className="h-5 w-5 shrink-0 fill-[var(--action-blue)] text-[var(--action-blue)]" strokeWidth={1.5} aria-hidden="true" />
|
||||
<span><span className="font-semibold text-slate-700">TJWater</span><span className="mx-2 text-slate-300" aria-hidden="true">/</span>智慧水务平台</span>
|
||||
</div>
|
||||
<DialogTitle className="mt-6 text-[22px] font-semibold leading-8 tracking-normal">切换项目</DialogTitle>
|
||||
<DialogDescription className="mt-1.5 break-words text-xs leading-5 text-slate-500">当前项目:{current?.name ?? current?.code ?? "当前项目"}</DialogDescription>
|
||||
</header>
|
||||
<form className="mt-6" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (pending || projectId === selection.selectedProjectId) return;
|
||||
setAttemptedProjectId(projectId);
|
||||
selection.onSelectProject(projectId);
|
||||
}}>
|
||||
<div>
|
||||
<label id={labelId} className="mb-2 block text-xs font-medium leading-4 text-slate-600">项目环境</label>
|
||||
<Select value={projectId} disabled={pending} onValueChange={(value) => { setProjectId(value); setAttemptedProjectId(null); }}>
|
||||
<SelectTrigger ref={projectTriggerRef} aria-labelledby={labelId} aria-describedby={error ? `${labelId}-error` : undefined} aria-invalid={!!error}
|
||||
className="h-auto min-h-20 gap-3 whitespace-normal rounded-xl border-[var(--surface-divider)] bg-[var(--surface-reading)] px-4 py-3.5 text-left shadow-none transition-colors hover:bg-[var(--surface-control)] data-[state=open]:border-[var(--action-blue)] motion-reduce:transition-none [&>span]:line-clamp-none [&>svg]:shrink-0">
|
||||
<SelectValue>
|
||||
<span className="block min-w-0 space-y-1.5">
|
||||
<span className="block break-words text-base font-medium leading-6">{selected?.name ?? selected?.code ?? "请选择项目"}</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-5 text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full bg-emerald-600" aria-hidden="true" />可用</span>
|
||||
{selected?.project_role ? <span>{roleLabels[selected.project_role] ?? selected.project_role}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={8} animateContent={!keyboard}
|
||||
className="acrylic-panel acrylic-panel-white z-[102] max-w-[calc(100vw-2rem)] rounded-xl border-[var(--surface-border)] shadow-[var(--shadow-panel)]">
|
||||
{selection.projects.map((project) => (
|
||||
<SelectItem key={project.project_id} value={project.project_id} className="min-h-16 rounded-lg py-2.5 text-sm [&>span:last-child]:min-w-0 [&>span:last-child]:whitespace-normal">
|
||||
<span className="block break-words font-medium">{project.name ?? project.code ?? project.gs_workspace}</span>
|
||||
{project.project_id === selection.selectedProjectId ? <span className="mt-1 block text-xs leading-5 text-slate-500">正在使用</span> : project.project_role ? <span className="mt-1 block text-xs leading-5 text-slate-500">{roleLabels[project.project_role] ?? project.project_role}</span> : null}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error ? <p id={`${labelId}-error`} role="alert" className="mt-2 text-xs leading-5 text-rose-700">{error}</p> : null}
|
||||
<Button type="submit" className="mt-6 h-11 w-full disabled:border-[var(--surface-divider)] disabled:bg-[var(--surface-control)] disabled:text-[var(--ink-muted)] disabled:opacity-100 disabled:shadow-none" disabled={projectId === selection.selectedProjectId} loading={pending} loadingLabel="正在进入项目">进入项目</Button>
|
||||
</form>
|
||||
</motion.section>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -109,11 +109,11 @@ type ExecutionStep = {
|
||||
};
|
||||
|
||||
type ConditionDetailPanelProps = {
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
condition: ScheduledConditionItem;
|
||||
dataSource?: WorkbenchRuntimeDataSource;
|
||||
workOrderDataSource?: WorkbenchWorkOrderDataSource;
|
||||
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => void;
|
||||
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
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>;
|
||||
|
||||
@@ -94,7 +94,7 @@ type ScheduledConditionFeedProps = {
|
||||
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onTaskRefresh?: () => void;
|
||||
onLoadTaskReports?: (taskId: string, offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
|
||||
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
};
|
||||
@@ -369,14 +369,15 @@ export function ScheduledConditionFeed({
|
||||
|
||||
if (condition.operationalTask) {
|
||||
onExpandAgent();
|
||||
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id, discussion))
|
||||
.catch((error) => showMapNotice({
|
||||
if (!onOpenTaskCollaboration) return false;
|
||||
return Promise.resolve(onOpenTaskCollaboration(condition.operationalTask.task_id, discussion))
|
||||
.then(() => true)
|
||||
.catch((error) => { showMapNotice({
|
||||
id: "operational-task-collaboration",
|
||||
tone: "error",
|
||||
title: "Agent 协作打开失败",
|
||||
message: error instanceof Error ? error.message : "无法打开这条任务的协作问答。"
|
||||
}));
|
||||
return;
|
||||
}); return false; });
|
||||
}
|
||||
|
||||
onExpandAgent();
|
||||
@@ -1103,6 +1104,7 @@ function ConditionTimelineRow({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-condition-id={condition.id}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
|
||||
@@ -232,7 +232,7 @@ export function ToolbarPanel({
|
||||
if (renderedToolId === "geocoding") {
|
||||
return (
|
||||
<MapControlPanel
|
||||
key="geocoding" title="地址定位" description="奉贤区 · 天地图地名搜索" icon={Icon}
|
||||
key="geocoding" title="地址定位" description={geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
|
||||
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col"
|
||||
visible={panelVisible}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
|
||||
import { useLayoutEffect, type ComponentProps } from "react";
|
||||
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
|
||||
import { useEffect, useRef, type ComponentProps, type ReactNode } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/shared/ui/resizable";
|
||||
import {
|
||||
clampAgentPanelWidth,
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchViewportLayout,
|
||||
WORKBENCH_LAYOUT
|
||||
} from "../layout/workbench-layout";
|
||||
|
||||
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
|
||||
|
||||
type WorkbenchAgentPanelsProps = {
|
||||
children: ReactNode;
|
||||
panelProps: AgentPanelProps;
|
||||
panelOpen: boolean;
|
||||
panelCollapsing: boolean;
|
||||
@@ -18,6 +26,7 @@ type WorkbenchAgentPanelsProps = {
|
||||
};
|
||||
|
||||
export function WorkbenchAgentPanels({
|
||||
children,
|
||||
panelProps,
|
||||
panelOpen,
|
||||
panelCollapsing,
|
||||
@@ -29,52 +38,100 @@ export function WorkbenchAgentPanels({
|
||||
onExpandPanel,
|
||||
onWidthCommit
|
||||
}: WorkbenchAgentPanelsProps) {
|
||||
const { handlePointerDown, panelRef, resizing, width } = useAgentPanelResize(
|
||||
viewportWidth,
|
||||
conditionExpanded
|
||||
);
|
||||
const panelRef = usePanelRef();
|
||||
const groupElement = useRef<HTMLDivElement>(null);
|
||||
const preferredWidth = useRef<number | null>(null);
|
||||
const desktop = viewportWidth >= WORKBENCH_LAYOUT.desktopMinWidth;
|
||||
const expanded = desktop && panelOpen;
|
||||
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onWidthCommit?.(width);
|
||||
}, [width, onWidthCommit]);
|
||||
// Restore pixels after the group's ResizeObserver has measured the new
|
||||
// viewport; a resize against its previous measurement would restore a ratio.
|
||||
useEffect(() => {
|
||||
const element = groupElement.current;
|
||||
if (!element) return;
|
||||
let frame = 0;
|
||||
const restore = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const target = expanded
|
||||
? clampAgentPanelWidth(
|
||||
preferredWidth.current ?? defaultWidth,
|
||||
element.clientWidth,
|
||||
conditionExpanded
|
||||
)
|
||||
: 0;
|
||||
panelRef.current?.resize(target);
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(restore);
|
||||
observer.observe(element);
|
||||
restore();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [expanded, defaultWidth, viewportWidth, conditionExpanded, panelRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block${resizing ? " select-none" : ""}`}
|
||||
style={{ width: panelOpen ? width : 0 }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-dock"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
{!panelCollapsing ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
data-resizing={resizing}
|
||||
onPointerDown={handlePointerDown}
|
||||
className="group pointer-events-auto absolute inset-y-0 -right-1.5 z-10 w-3 cursor-col-resize touch-none"
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-y-0 left-[5px] w-px bg-transparent transition-colors duration-150 group-hover:bg-[var(--action-blue-hover)]/70 group-data-[resizing=true]:bg-[var(--action-blue)]/80 motion-reduce:transition-none" />
|
||||
<span className="agent-resize-grip pointer-events-none absolute left-1/2 top-1/2 h-11 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--action-blue)] opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[resizing=true]:bg-[var(--action-blue)] group-data-[resizing=true]:opacity-100 motion-reduce:transition-none" />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute left-3 top-5">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<ResizablePanelGroup
|
||||
id="workbench-columns"
|
||||
elementRef={groupElement}
|
||||
orientation="horizontal"
|
||||
disabled={!expanded || panelCollapsing}
|
||||
resizeTargetMinimumSize={{ fine: 12, coarse: 24 }}
|
||||
onLayoutChanged={(layout, { isUserInteraction }) => {
|
||||
if (isUserInteraction && expanded) {
|
||||
// Keyboard callbacks precede the panel's DOM update. Use the new
|
||||
// layout rather than getSize().inPixels, which still reads the old width.
|
||||
const groupWidth = groupElement.current?.clientWidth;
|
||||
if (groupWidth)
|
||||
preferredWidth.current = Math.round((layout["agent-column"] * groupWidth) / 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="agent-column"
|
||||
panelRef={panelRef}
|
||||
defaultSize={expanded ? defaultWidth : 0}
|
||||
minSize={expanded ? WORKBENCH_LAYOUT.minAgentWidth : 0}
|
||||
maxSize={expanded ? getAgentPanelMaxWidth(viewportWidth, conditionExpanded) : 0}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
onResize={({ inPixels }) => {
|
||||
if (expanded && inPixels > 0) onWidthCommit?.(Math.round(inPixels));
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-auto hidden h-full min-h-0 pt-14 lg:block">
|
||||
{panelOpen ? (
|
||||
<AgentCommandPanel
|
||||
{...panelProps}
|
||||
presentation="desktop-dock"
|
||||
collapsing={panelCollapsing}
|
||||
onCollapse={onCollapsePanel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
withHandle
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
disabled={!expanded || panelCollapsing}
|
||||
className={`pointer-events-auto z-50 h-[calc(100%-3.5rem)] w-0 self-end ${expanded ? "" : "hidden"}`}
|
||||
/>
|
||||
<ResizablePanel id="main-column" minSize={0} className="relative h-full min-h-0">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
{!panelOpen ? (
|
||||
<div className="pointer-events-none absolute left-0 top-14 z-50 hidden p-3 lg:block">
|
||||
<AgentCollapsedRail
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
onExpand={onExpandPanel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowRightLeft,
|
||||
Bell,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
UserRound,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { ProjectSwitchDialog } from "./project-switch-dialog";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchProject } from "../api/project-client";
|
||||
import type { WorkbenchAlert, WorkbenchUser } from "../types";
|
||||
import {
|
||||
compactHeaderControlButtonClassName,
|
||||
@@ -259,10 +263,22 @@ export function AlertMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export type WorkbenchProjectSelection = {
|
||||
projects: WorkbenchProject[];
|
||||
selectedProjectId: string;
|
||||
pendingProjectId: string | null;
|
||||
error: string | null;
|
||||
onSelectProject: (projectId: string) => void;
|
||||
onOpen?: () => void;
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
};
|
||||
|
||||
export function UserMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
projectSelection,
|
||||
onRefreshTiles,
|
||||
onShowDataStatus,
|
||||
onShowShortcuts,
|
||||
@@ -272,16 +288,23 @@ export function UserMenu({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: WorkbenchUser;
|
||||
projectSelection?: WorkbenchProjectSelection;
|
||||
onRefreshTiles: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onExportConfig: () => void;
|
||||
onLogout?: () => Promise<void>;
|
||||
}) {
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||
const [projectDialogKeyboard, setProjectDialogKeyboard] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
|
||||
@@ -297,8 +320,18 @@ export function UserMenu({
|
||||
<ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
|
||||
<DropdownMenuContent align="end" className={cn("z-[90] w-72", menuSurfaceClassName)}
|
||||
onKeyDownCapture={() => setProjectDialogKeyboard(true)}
|
||||
onPointerDownCapture={() => setProjectDialogKeyboard(false)}
|
||||
onCloseAutoFocus={(event) => { if (projectDialogOpen) event.preventDefault(); }}>
|
||||
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
|
||||
{projectSelection ? (
|
||||
<>
|
||||
<MenuAction icon={ArrowRightLeft} label="切换项目" description="选择要进入的供水项目"
|
||||
onSelect={() => { onOpenChange(false); setProjectDialogOpen(true); projectSelection.onOpen?.(); }} />
|
||||
<MenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
||||
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
||||
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
||||
@@ -319,6 +352,12 @@ export function UserMenu({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AnimatePresence>
|
||||
{projectDialogOpen && projectSelection ? <ProjectSwitchDialog selection={projectSelection} keyboardOpened={projectDialogKeyboard}
|
||||
onClose={() => setProjectDialogOpen(false)}
|
||||
onRestoreFocus={() => triggerRef.current?.focus()} /> : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
WorkbenchUser
|
||||
} from "../types";
|
||||
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
|
||||
import { AlertMenu, AnalysisViewMenu, UserMenu } from "./workbench-top-bar-menus";
|
||||
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
|
||||
import {
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
@@ -34,6 +34,7 @@ export type WorkbenchTopBarProps = {
|
||||
alerts: WorkbenchAlert[];
|
||||
alertDataSource: WorkbenchAlertDataSource;
|
||||
user: WorkbenchUser;
|
||||
projectSelection?: WorkbenchProjectSelection;
|
||||
conditionFeedVisible: boolean;
|
||||
timelineVisible: boolean;
|
||||
taskTickerAvailable: boolean;
|
||||
@@ -71,6 +72,7 @@ export function WorkbenchTopBar({
|
||||
alerts,
|
||||
alertDataSource,
|
||||
user,
|
||||
projectSelection,
|
||||
conditionFeedVisible,
|
||||
timelineVisible,
|
||||
taskTickerAvailable,
|
||||
@@ -184,14 +186,14 @@ export function WorkbenchTopBar({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="外围样板管理"
|
||||
aria-label="外围系统"
|
||||
aria-pressed={peripheralSamplePanelOpen}
|
||||
title="外围样板管理"
|
||||
title="外围系统"
|
||||
onClick={onTogglePeripheralSamplePanel}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}><RadioTower size={14} aria-hidden="true" /></span>
|
||||
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围样板</span>
|
||||
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围系统</span>
|
||||
</button>
|
||||
<div className="lg:hidden">
|
||||
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
|
||||
@@ -250,6 +252,7 @@ export function WorkbenchTopBar({
|
||||
open={openMenu === "user"}
|
||||
onOpenChange={createMenuOpenChange("user")}
|
||||
user={user}
|
||||
projectSelection={projectSelection}
|
||||
onRefreshTiles={onRefreshTiles}
|
||||
onShowDataStatus={onShowDataStatus}
|
||||
onShowShortcuts={onShowShortcuts}
|
||||
|
||||
Reference in New Issue
Block a user