feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -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}