feat: add adaptive agent workspace windows
This commit is contained in:
@@ -98,6 +98,8 @@ export function ScheduledConditionFeed({
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||
const mobileSheet = presentation === "mobile-sheet";
|
||||
const desktopDock = presentation === "desktop-dock";
|
||||
const compactPresentation = mobileSheet || desktopDock;
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
@@ -298,7 +300,7 @@ export function ScheduledConditionFeed({
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded
|
||||
expanded && !desktopDock
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
: "w-[var(--workbench-condition-width)]"
|
||||
)
|
||||
@@ -354,7 +356,7 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
mobileSheet
|
||||
compactPresentation
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
@@ -364,7 +366,7 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet
|
||||
compactPresentation
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
@@ -386,7 +388,7 @@ export function ScheduledConditionFeed({
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
mobileSheet && (expanded ? "hidden" : "h-full w-full")
|
||||
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
|
||||
@@ -166,7 +166,18 @@ export function useWorkbenchMap({
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
});
|
||||
|
||||
let resizeFrame: number | null = null;
|
||||
const resizeMap = () => map.resize();
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
resizeFrame = null;
|
||||
resizeMap();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
window.addEventListener("resize", resizeMap);
|
||||
|
||||
map.on("sourcedata", (event) => {
|
||||
@@ -198,6 +209,10 @@ export function useWorkbenchMap({
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeMap);
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
|
||||
@@ -35,7 +35,6 @@ import { MapDevPanel } from "./components/map-dev-panel";
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
import {
|
||||
ToolbarPanel,
|
||||
type ExportViewPreset,
|
||||
@@ -43,6 +42,10 @@ import {
|
||||
type ToolbarToolId
|
||||
} from "./components/toolbar-panel";
|
||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||
import {
|
||||
WorkbenchMainFrame,
|
||||
type WorkbenchNavigationSection
|
||||
} from "./workspace/workbench-main-frame";
|
||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||
@@ -101,6 +104,8 @@ export function MapWorkbenchPage({
|
||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||||
const [mainFrameSection, setMainFrameSection] = useState<WorkbenchNavigationSection>("agent");
|
||||
const [mainFrameNavigationCollapsed, setMainFrameNavigationCollapsed] = useState(false);
|
||||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
@@ -149,7 +154,6 @@ export function MapWorkbenchPage({
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
isLargeScreen,
|
||||
leftPanelOpen,
|
||||
@@ -159,11 +163,8 @@ export function MapWorkbenchPage({
|
||||
openConditionFeedForViewport,
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setMobileSheetSnap,
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
viewportWidth
|
||||
toggleConditionFeedForViewport
|
||||
} = useWorkbenchResponsiveLayout({
|
||||
activeToolOpen: activeToolId !== null,
|
||||
agentPanelOpen: agent.panelOpen,
|
||||
@@ -173,6 +174,31 @@ export function MapWorkbenchPage({
|
||||
onClearActiveTool: clearActiveTool
|
||||
});
|
||||
|
||||
function openAgentWorkspaceForViewport() {
|
||||
if (isLargeScreen) {
|
||||
setMainFrameSection("agent");
|
||||
setMainFrameNavigationCollapsed(false);
|
||||
}
|
||||
openAgentPanelForViewport();
|
||||
}
|
||||
|
||||
function openConditionWorkspaceForViewport() {
|
||||
if (isLargeScreen) {
|
||||
setMainFrameSection("conditions");
|
||||
setMainFrameNavigationCollapsed(false);
|
||||
}
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
|
||||
function toggleConditionWorkspaceForViewport() {
|
||||
if (isLargeScreen) {
|
||||
setMainFrameSection((current) => current === "conditions" ? "agent" : "conditions");
|
||||
setMainFrameNavigationCollapsed(false);
|
||||
return;
|
||||
}
|
||||
toggleConditionFeedForViewport();
|
||||
}
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
impactVisible,
|
||||
@@ -182,9 +208,9 @@ export function MapWorkbenchPage({
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
leftPanelOpen: isLargeScreen ? false : leftPanelOpen,
|
||||
rightPanelOpen: isLargeScreen ? devPanelOpen : rightPanelOpen,
|
||||
conditionPanelExpanded: isLargeScreen ? false : rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
|
||||
@@ -442,7 +468,7 @@ export function MapWorkbenchPage({
|
||||
return;
|
||||
}
|
||||
|
||||
openAgentPanelForViewport();
|
||||
openAgentWorkspaceForViewport();
|
||||
|
||||
try {
|
||||
await agent.submitPrompt(
|
||||
@@ -479,7 +505,7 @@ export function MapWorkbenchPage({
|
||||
return;
|
||||
}
|
||||
|
||||
openConditionFeedForViewport();
|
||||
openConditionWorkspaceForViewport();
|
||||
setConditionFocusRequest((current) => ({
|
||||
conditionId,
|
||||
requestId: (current?.requestId ?? 0) + 1
|
||||
@@ -601,9 +627,9 @@ export function MapWorkbenchPage({
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
rightPanelExpanded,
|
||||
isLargeScreen ? false : leftPanelOpen,
|
||||
isLargeScreen ? devPanelOpen : rightPanelOpen,
|
||||
isLargeScreen ? false : rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
),
|
||||
duration: trustedAction.durationMs ?? 500
|
||||
@@ -764,13 +790,6 @@ export function MapWorkbenchPage({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="供水管网地图"
|
||||
/>
|
||||
|
||||
<WorkbenchTopBar
|
||||
dataTime={headerDataTime}
|
||||
modelName="AquaDispatch v1.0"
|
||||
@@ -778,14 +797,14 @@ export function MapWorkbenchPage({
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={user}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
conditionFeedVisible={isLargeScreen ? mainFrameSection === "conditions" && !mainFrameNavigationCollapsed : mobileSheet === "condition"}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
devPanelEnabled={devPanelEnabled}
|
||||
devPanelOpen={devPanelOpen}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
onSelectAlert={handleSelectAlert}
|
||||
onToggleConditionFeed={toggleConditionFeedForViewport}
|
||||
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
|
||||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||||
onPreviewScenario={handlePreviewScenario}
|
||||
@@ -799,50 +818,73 @@ export function MapWorkbenchPage({
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
<WorkbenchAgentPanels
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
viewportWidth={viewportWidth}
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onWidthCommit={setAgentPanelWidth}
|
||||
/>
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-2 top-24 z-20 hidden lg:block">
|
||||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{conditionFeedMounted && !devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<WorkbenchMainFrame
|
||||
testEnabled={devPanelEnabled}
|
||||
activeSection={mainFrameSection}
|
||||
navigationCollapsed={mainFrameNavigationCollapsed}
|
||||
onActiveSectionChange={setMainFrameSection}
|
||||
onNavigationCollapsedChange={setMainFrameNavigationCollapsed}
|
||||
renderAgentPanel={(onCollapse) => (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
presentation="desktop-dock"
|
||||
onCollapse={onCollapse}
|
||||
/>
|
||||
)}
|
||||
conditionsPanel={(
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
presentation="desktop-dock"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible={shouldShowConditionFeed}
|
||||
visible
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentPanelForViewport}
|
||||
onExpandAgent={openAgentWorkspaceForViewport}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
layersPanel={(
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3">
|
||||
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full flex-wrap" />
|
||||
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full" />
|
||||
</div>
|
||||
)}
|
||||
toolsPanel={(
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3">
|
||||
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full flex-wrap" />
|
||||
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full" />
|
||||
</div>
|
||||
)}
|
||||
mapContent={(
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid absolute inset-0"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="供水管网地图"
|
||||
/>
|
||||
)}
|
||||
mapOverlay={(
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
|
||||
<MapToolbar items={toolbarItems} />
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 flex flex-col items-end gap-2">
|
||||
<div className="flex items-end gap-2 pr-2">
|
||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||
</div>
|
||||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{devPanelEnabled && devPanelOpen ? (
|
||||
<MapDevPanel
|
||||
@@ -862,13 +904,6 @@ export function MapWorkbenchPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
|
||||
<div className="flex items-end gap-2 pr-2">
|
||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||
</div>
|
||||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowTaskTicker ? (
|
||||
<motion.div
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { AnalysisDocument } from "./workspace-model";
|
||||
|
||||
type FixtureFactory = (id: string, generatedAt: string) => AnalysisDocument;
|
||||
|
||||
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
title: "北辰分区压力异常诊断",
|
||||
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
|
||||
generatedAt,
|
||||
blocks: [
|
||||
{
|
||||
id: "pressure-metrics",
|
||||
kind: "metric-grid",
|
||||
title: "诊断摘要",
|
||||
span: 12,
|
||||
metrics: [
|
||||
{ label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" },
|
||||
{ label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" },
|
||||
{ label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" },
|
||||
{ label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "pressure-trend",
|
||||
kind: "chart",
|
||||
title: "关键节点压力曲线",
|
||||
description: "BC-P-014 在 09:40 后持续低于调度下限",
|
||||
span: 7,
|
||||
chartType: "line",
|
||||
categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"],
|
||||
unit: "MPa",
|
||||
series: [
|
||||
{ name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" },
|
||||
{ name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" },
|
||||
{ name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "pressure-ranking",
|
||||
kind: "chart",
|
||||
title: "异常偏差排名",
|
||||
description: "按相对调度下限的压力偏差排序",
|
||||
span: 5,
|
||||
chartType: "bar",
|
||||
categories: ["P-014", "P-008", "P-026", "P-031", "P-017"],
|
||||
unit: "%",
|
||||
series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }]
|
||||
},
|
||||
{
|
||||
id: "pressure-evidence",
|
||||
kind: "data-table",
|
||||
title: "证据链",
|
||||
description: "对诊断结论贡献最高的观测证据",
|
||||
span: 12,
|
||||
columns: [
|
||||
{ key: "time", label: "时间" },
|
||||
{ key: "source", label: "数据源" },
|
||||
{ key: "finding", label: "观测" },
|
||||
{ key: "impact", label: "影响" },
|
||||
{ key: "confidence", label: "置信度", numeric: true }
|
||||
],
|
||||
rows: [
|
||||
{ time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" },
|
||||
{ time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" },
|
||||
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
title: "供水服务分区平衡分析",
|
||||
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
|
||||
generatedAt,
|
||||
blocks: [
|
||||
{
|
||||
id: "zone-metrics",
|
||||
kind: "metric-grid",
|
||||
title: "分区态势",
|
||||
span: 12,
|
||||
metrics: [
|
||||
{ label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" },
|
||||
{ label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" },
|
||||
{ label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" },
|
||||
{ label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zone-balance",
|
||||
kind: "chart",
|
||||
title: "分区供需对比",
|
||||
description: "北辰与东丽当前需求已接近可用供水能力",
|
||||
span: 7,
|
||||
chartType: "bar",
|
||||
categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"],
|
||||
unit: "万m³/d",
|
||||
series: [
|
||||
{ name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" },
|
||||
{ name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zone-risk",
|
||||
kind: "data-table",
|
||||
title: "调配优先级",
|
||||
description: "按负荷率、连续供水和通道可用性综合排序",
|
||||
span: 5,
|
||||
columns: [
|
||||
{ key: "zone", label: "分区" },
|
||||
{ key: "load", label: "负荷率", numeric: true },
|
||||
{ key: "risk", label: "风险" },
|
||||
{ key: "action", label: "建议" }
|
||||
],
|
||||
rows: [
|
||||
{ zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" },
|
||||
{ zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" },
|
||||
{ zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" },
|
||||
{ zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zone-note",
|
||||
kind: "narrative",
|
||||
title: "Agent 结论",
|
||||
span: 12,
|
||||
paragraphs: [
|
||||
"优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。",
|
||||
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
|
||||
id,
|
||||
title: "低压事件调度处置流程",
|
||||
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
|
||||
generatedAt,
|
||||
blocks: [
|
||||
{
|
||||
id: "flow-metrics",
|
||||
kind: "metric-grid",
|
||||
title: "执行状态",
|
||||
span: 12,
|
||||
metrics: [
|
||||
{ label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" },
|
||||
{ label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" },
|
||||
{ label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" },
|
||||
{ label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dispatch-process",
|
||||
kind: "process-flow",
|
||||
title: "处置路径",
|
||||
description: "所有调度动作均需在执行前完成权限和现场状态确认",
|
||||
span: 7,
|
||||
nodes: [
|
||||
{ id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" },
|
||||
{ id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" },
|
||||
{ id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" },
|
||||
{ id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" },
|
||||
{ id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dispatch-checklist",
|
||||
kind: "data-table",
|
||||
title: "执行清单",
|
||||
description: "当前阶段需要完成的人员与系统确认",
|
||||
span: 5,
|
||||
columns: [
|
||||
{ key: "owner", label: "责任方" },
|
||||
{ key: "item", label: "确认项" },
|
||||
{ key: "state", label: "状态" }
|
||||
],
|
||||
rows: [
|
||||
{ owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" },
|
||||
{ owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" },
|
||||
{ owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" },
|
||||
{ owner: "系统管理员", item: "确认远控操作权限", state: "待处理" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dispatch-progress",
|
||||
kind: "chart",
|
||||
title: "处置前后压力预测",
|
||||
description: "模拟结果显示执行后 20 分钟内压力回到调度区间",
|
||||
span: 12,
|
||||
chartType: "line",
|
||||
categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"],
|
||||
unit: "MPa",
|
||||
series: [
|
||||
{ name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" },
|
||||
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
|
||||
|
||||
export function createAnalysisDocumentFixture(index: number, instance: number): AnalysisDocument {
|
||||
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
|
||||
const now = new Date();
|
||||
return factory(
|
||||
`analysis-${now.getTime().toString(36)}-${instance}`,
|
||||
now.toLocaleTimeString("zh-CN", { hour12: false })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { Activity, Check, Clock3, FileSearch, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type {
|
||||
AnalysisBlock,
|
||||
AnalysisDocument,
|
||||
AnalysisMetric
|
||||
} from "./workspace-model";
|
||||
|
||||
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) {
|
||||
return (
|
||||
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN">
|
||||
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Agent 临时分析文档
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold leading-7 text-slate-950">{document.title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">{document.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-600">
|
||||
<Clock3 size={13} aria-hidden="true" />
|
||||
{document.generatedAt}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5">
|
||||
{document.blocks.map((block) => (
|
||||
<AnalysisBlockView key={block.id} block={block} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisBlockView({ block }: { block: AnalysisBlock }) {
|
||||
const spanClass = block.span === 5 ? "col-span-12 xl:col-span-5" : block.span === 7 ? "col-span-12 xl:col-span-7" : "col-span-12";
|
||||
|
||||
if (block.kind === "metric-grid") {
|
||||
return (
|
||||
<section className={spanClass} aria-labelledby={`${block.id}-title`}>
|
||||
<h3 id={`${block.id}-title`} className="sr-only">{block.title}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 2xl:grid-cols-4">
|
||||
{block.metrics.map((metric) => (
|
||||
<MetricTile key={metric.label} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.kind === "chart") {
|
||||
return (
|
||||
<AnalysisSection block={block} className={spanClass}>
|
||||
<div className="h-[258px] min-h-0">
|
||||
<ReactECharts
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
option={createChartOption(block)}
|
||||
/>
|
||||
</div>
|
||||
</AnalysisSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.kind === "process-flow") {
|
||||
return (
|
||||
<AnalysisSection block={block} className={spanClass}>
|
||||
<ol className="space-y-0" aria-label={block.title}>
|
||||
{block.nodes.map((node, index) => (
|
||||
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
|
||||
{index < block.nodes.length - 1 ? (
|
||||
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
|
||||
node.status === "complete" && "bg-emerald-100 text-emerald-700",
|
||||
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
|
||||
node.status === "pending" && "bg-slate-100 text-slate-500"
|
||||
)}
|
||||
>
|
||||
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
|
||||
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">当前</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</AnalysisSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.kind === "data-table") {
|
||||
return (
|
||||
<AnalysisSection block={block} className={spanClass}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
|
||||
{block.columns.map((column) => (
|
||||
<th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, index) => (
|
||||
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
|
||||
{block.columns.map((column) => (
|
||||
<td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AnalysisSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisSection block={block} className={spanClass}>
|
||||
<div className="space-y-3 text-sm leading-7 text-slate-700">
|
||||
{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
</AnalysisSection>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisSection({
|
||||
block,
|
||||
className,
|
||||
children
|
||||
}: {
|
||||
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("min-w-0 overflow-hidden rounded-lg bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]", className)} aria-labelledby={`${block.id}-title`}>
|
||||
<header className="flex items-start gap-3 border-b border-slate-100 px-4 py-3.5">
|
||||
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700"><FileSearch size={16} aria-hidden="true" /></span>
|
||||
<div className="min-w-0">
|
||||
<h3 id={`${block.id}-title`} className="text-sm font-semibold text-slate-950">{block.title}</h3>
|
||||
{"description" in block ? <p className="mt-1 text-xs leading-5 text-slate-500">{block.description}</p> : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTile({ metric }: { metric: AnalysisMetric }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg bg-white px-4 py-3.5 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
|
||||
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xl font-semibold text-slate-950 tabular-nums">{metric.value}</p>
|
||||
<p className="mt-1 truncate text-xs text-slate-500" title={metric.detail}>{metric.detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
|
||||
return {
|
||||
animationDuration: 260,
|
||||
animationEasing: "cubicOut",
|
||||
color: block.series.map((series) => series.color),
|
||||
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
|
||||
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
|
||||
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
|
||||
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
|
||||
series: block.series.map((series) => ({
|
||||
name: series.name,
|
||||
type: series.type ?? block.chartType,
|
||||
data: series.values,
|
||||
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
barMaxWidth: 24,
|
||||
lineStyle: { width: 2.5 },
|
||||
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function metricToneClass(tone: AnalysisMetric["tone"]) {
|
||||
if (tone === "danger") return "bg-rose-500";
|
||||
if (tone === "warning") return "bg-amber-500";
|
||||
if (tone === "success") return "bg-emerald-500";
|
||||
if (tone === "info") return "bg-blue-600";
|
||||
return "bg-slate-400";
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
Bot,
|
||||
CalendarClock,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
FlaskConical,
|
||||
Layers3,
|
||||
Map as MapIcon,
|
||||
PanelLeft,
|
||||
SlidersHorizontal,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { AnalysisDocumentView } from "./analysis-document-view";
|
||||
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
focusWorkspaceWindow,
|
||||
maximizeWorkspaceWindow,
|
||||
minimizeWorkspaceWindow,
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
updateWorkspaceWindowRect,
|
||||
type WorkspaceBounds
|
||||
} from "./workspace-model";
|
||||
import { WorkspaceWindow } from "./workspace-window";
|
||||
|
||||
export type WorkbenchNavigationSection = "agent" | "conditions" | "layers" | "tools";
|
||||
|
||||
type WorkbenchMainFrameProps = {
|
||||
renderAgentPanel: (onCollapse: () => void) => ReactNode;
|
||||
conditionsPanel: ReactNode;
|
||||
layersPanel: ReactNode;
|
||||
toolsPanel: ReactNode;
|
||||
mapContent: ReactNode;
|
||||
mapOverlay?: ReactNode;
|
||||
testEnabled: boolean;
|
||||
activeSection: WorkbenchNavigationSection;
|
||||
navigationCollapsed: boolean;
|
||||
onActiveSectionChange: (section: WorkbenchNavigationSection) => void;
|
||||
onNavigationCollapsedChange: (collapsed: boolean) => void;
|
||||
};
|
||||
|
||||
const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; icon: typeof Bot }> = [
|
||||
{ id: "agent", label: "Agent", icon: Bot },
|
||||
{ id: "conditions", label: "工况任务", icon: CalendarClock },
|
||||
{ id: "layers", label: "图层", icon: Layers3 },
|
||||
{ id: "tools", label: "地图工具", icon: SlidersHorizontal }
|
||||
];
|
||||
|
||||
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||
|
||||
export function WorkbenchMainFrame({
|
||||
renderAgentPanel,
|
||||
conditionsPanel,
|
||||
layersPanel,
|
||||
toolsPanel,
|
||||
mapContent,
|
||||
mapOverlay,
|
||||
testEnabled,
|
||||
activeSection,
|
||||
navigationCollapsed,
|
||||
onActiveSectionChange,
|
||||
onNavigationCollapsedChange
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
const fixtureIndexRef = useRef(0);
|
||||
const fixtureInstanceRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = workspaceRef.current;
|
||||
if (!element) return;
|
||||
const updateBounds = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
const nextBounds = { width: rect.width, height: rect.height };
|
||||
setBounds(nextBounds);
|
||||
setWorkspace((current) => resizeWorkspace(current, nextBounds));
|
||||
}
|
||||
};
|
||||
updateBounds();
|
||||
const observer = new ResizeObserver(updateBounds);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||
const handleChange = () => {
|
||||
setDesktop(mediaQuery.matches);
|
||||
};
|
||||
handleChange();
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const openTestDocument = useCallback(() => {
|
||||
if (workspace.analyses.length >= MAX_ANALYSIS_WINDOWS) {
|
||||
showMapNotice({
|
||||
tone: "warning",
|
||||
title: "临时窗口已达上限",
|
||||
message: "请关闭一个 Agent 分析窗口后继续测试。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const document = createAnalysisDocumentFixture(fixtureIndexRef.current, fixtureInstanceRef.current);
|
||||
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
|
||||
fixtureInstanceRef.current += 1;
|
||||
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
|
||||
}, [bounds, workspace.analyses.length]);
|
||||
|
||||
const navigationWidth = navigationCollapsed ? 48 : 368;
|
||||
return (
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
|
||||
data-testid="workbench-main-frame"
|
||||
style={{ "--workbench-frame-navigation-width": `${navigationWidth}px` } as CSSProperties}
|
||||
>
|
||||
<aside
|
||||
aria-label="工作台导航"
|
||||
className="pointer-events-auto absolute bottom-0 left-0 top-0 z-30 hidden overflow-hidden border-r border-slate-300 bg-[#edf1f5] shadow-[1px_0_3px_rgba(15,23,42,0.12)] lg:flex"
|
||||
style={{ width: navigationWidth }}
|
||||
>
|
||||
<nav className="flex w-12 shrink-0 flex-col items-center border-r border-slate-300 bg-[#e2e8ef] py-2" aria-label="主工作区">
|
||||
<span className="mb-2 grid h-9 w-9 place-items-center rounded-md bg-slate-900 text-white"><PanelLeft size={17} aria-hidden="true" /></span>
|
||||
{NAVIGATION_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.id === activeSection;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
aria-pressed={active}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
"relative grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : "hover:bg-white/70 hover:text-slate-900"
|
||||
)}
|
||||
onClick={() => { onActiveSectionChange(item.id); onNavigationCollapsedChange(false); }}
|
||||
>
|
||||
<Icon size={19} aria-hidden="true" />
|
||||
{active ? <span className="absolute bottom-1.5 h-1 w-1 rounded-full bg-blue-600" aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="mt-auto flex flex-col items-center gap-1">
|
||||
{testEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="测试打开 Agent 多图表窗口"
|
||||
title="测试打开 Agent 多图表窗口"
|
||||
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
|
||||
className="grid h-11 w-11 place-items-center rounded-md text-blue-700 transition-[background-color,color,scale] hover:bg-blue-50 active:scale-[0.96] disabled:text-slate-400 disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
onClick={openTestDocument}
|
||||
>
|
||||
<FlaskConical size={19} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
|
||||
title={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
|
||||
className="grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] hover:bg-white active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
onClick={() => onNavigationCollapsedChange(!navigationCollapsed)}
|
||||
>
|
||||
{navigationCollapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
aria-hidden={navigationCollapsed}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col bg-[#f7f9fb]",
|
||||
navigationCollapsed && "invisible"
|
||||
)}
|
||||
>
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 px-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600" aria-hidden="true" />
|
||||
<h2 className="truncate text-sm font-semibold text-slate-900">{NAVIGATION_ITEMS.find((item) => item.id === activeSection)?.label}</h2>
|
||||
</div>
|
||||
{testEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTestDocument}
|
||||
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
|
||||
className="flex h-9 items-center gap-1.5 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(37,99,235,0.3)] transition-[background-color,scale] hover:bg-blue-700 active:scale-[0.96] disabled:bg-slate-300 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
测试分析窗
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden [--workbench-condition-width:320px]">
|
||||
{desktop ? (
|
||||
<>
|
||||
<NavigationPanel active={activeSection === "agent"}>
|
||||
{renderAgentPanel(() => onNavigationCollapsedChange(true))}
|
||||
</NavigationPanel>
|
||||
<NavigationPanel active={activeSection === "conditions"}>{conditionsPanel}</NavigationPanel>
|
||||
<NavigationPanel active={activeSection === "layers"}>{layersPanel}</NavigationPanel>
|
||||
<NavigationPanel active={activeSection === "tools"}>{toolsPanel}</NavigationPanel>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
ref={workspaceRef}
|
||||
id="main-workspace"
|
||||
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]"
|
||||
>
|
||||
<WorkspaceWindow
|
||||
id={workspace.map.id}
|
||||
title={workspace.map.title}
|
||||
mode={desktop ? workspace.map.mode : "docked"}
|
||||
rect={workspace.map.rect}
|
||||
zIndex={workspace.map.zIndex}
|
||||
active={workspace.activeWindowId === workspace.map.id}
|
||||
kind="map"
|
||||
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
|
||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
|
||||
>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</WorkspaceWindow>
|
||||
|
||||
{workspace.analyses.map((window) => (
|
||||
<WorkspaceWindow
|
||||
key={window.id}
|
||||
id={window.id}
|
||||
title={window.title}
|
||||
mode={window.mode}
|
||||
rect={window.rect}
|
||||
zIndex={window.zIndex}
|
||||
active={workspace.activeWindowId === window.id}
|
||||
kind="analysis"
|
||||
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
|
||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
|
||||
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id))}
|
||||
>
|
||||
<AnalysisDocumentView document={window.document} />
|
||||
</WorkspaceWindow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-auto absolute bottom-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:left-[var(--workbench-frame-navigation-width)] lg:flex"
|
||||
role="toolbar"
|
||||
aria-label="工作区窗口任务栏"
|
||||
>
|
||||
<TaskbarButton
|
||||
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
|
||||
minimized={workspace.map.mode === "minimized"}
|
||||
icon={<MapIcon size={15} aria-hidden="true" />}
|
||||
label="供水管网地图"
|
||||
onClick={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
|
||||
/>
|
||||
{workspace.analyses.map((window) => (
|
||||
<TaskbarButton
|
||||
key={window.id}
|
||||
active={workspace.activeWindowId === window.id && window.mode !== "minimized"}
|
||||
minimized={window.mode === "minimized"}
|
||||
icon={<Sparkles size={14} aria-hidden="true" />}
|
||||
label={window.title}
|
||||
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} 个临时分析窗</span>
|
||||
</div>
|
||||
{!desktop ? <span className="sr-only">移动端保持地图与抽屉工作台</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${minimized ? "恢复" : "切换到"}${label}`}
|
||||
title={label}
|
||||
className={cn(
|
||||
"flex h-8 max-w-56 items-center gap-2 rounded-md px-3 text-xs font-medium transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : minimized ? "bg-slate-200 text-slate-600 hover:bg-white" : "text-slate-700 hover:bg-white/80"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
maximizeWorkspaceWindow,
|
||||
minimizeWorkspaceWindow,
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
updateWorkspaceWindowRect,
|
||||
type AnalysisDocument
|
||||
} from "./workspace-model";
|
||||
|
||||
const bounds = { width: 1180, height: 760 };
|
||||
|
||||
function documentAt(index: number): AnalysisDocument {
|
||||
return {
|
||||
id: `analysis-${index}`,
|
||||
title: `分析 ${index}`,
|
||||
subtitle: "测试",
|
||||
generatedAt: "10:40:00",
|
||||
blocks: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace window lifecycle", () => {
|
||||
it("minimizes the map for the first analysis and restores it after the last window closes", () => {
|
||||
const initial = createInitialWorkspaceState(bounds);
|
||||
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
|
||||
|
||||
expect(opened.map.mode).toBe("minimized");
|
||||
expect(opened.analyses).toHaveLength(1);
|
||||
|
||||
const closed = closeAnalysisWindow(opened, "analysis-1");
|
||||
expect(closed.analyses).toHaveLength(0);
|
||||
expect(closed.map.mode).toBe("docked");
|
||||
expect(closed.activeWindowId).toBe("workspace-map");
|
||||
});
|
||||
|
||||
it("supports minimize, maximize and bounded restore", () => {
|
||||
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
|
||||
const minimized = minimizeWorkspaceWindow(opened, "analysis-1");
|
||||
expect(minimized.analyses[0]?.mode).toBe("minimized");
|
||||
|
||||
const restored = restoreWorkspaceWindow(minimized, "analysis-1", bounds);
|
||||
expect(restored.analyses[0]?.mode).toBe("floating");
|
||||
|
||||
const maximized = maximizeWorkspaceWindow(restored, "analysis-1");
|
||||
expect(maximized.analyses[0]?.mode).toBe("maximized");
|
||||
|
||||
const resized = updateWorkspaceWindowRect(
|
||||
maximized,
|
||||
"analysis-1",
|
||||
{ x: -100, y: -80, width: 4000, height: 3000 },
|
||||
bounds
|
||||
);
|
||||
expect(resized.analyses[0]?.rect).toEqual({ x: 12, y: 12, width: 1156, height: 736 });
|
||||
});
|
||||
|
||||
it("caps transient analysis windows without deleting an existing result", () => {
|
||||
const state = Array.from({ length: MAX_ANALYSIS_WINDOWS }, (_, index) => index + 1).reduce(
|
||||
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
|
||||
createInitialWorkspaceState(bounds)
|
||||
);
|
||||
|
||||
const overflow = openAnalysisDocument(state, documentAt(99), bounds);
|
||||
expect(overflow).toBe(state);
|
||||
expect(overflow.analyses).toHaveLength(MAX_ANALYSIS_WINDOWS);
|
||||
});
|
||||
|
||||
it("reclamps current and restore rectangles when the workspace shrinks", () => {
|
||||
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
|
||||
const moved = updateWorkspaceWindowRect(
|
||||
opened,
|
||||
"analysis-1",
|
||||
{ x: 500, y: 260, width: 660, height: 480 },
|
||||
bounds
|
||||
);
|
||||
|
||||
const resized = resizeWorkspace(moved, { width: 800, height: 600 });
|
||||
const analysis = resized.analyses[0];
|
||||
expect(analysis?.rect).toEqual({ x: 128, y: 108, width: 660, height: 480 });
|
||||
expect(analysis?.restoreRect).toEqual(analysis?.rect);
|
||||
expect(resized.map.restoreRect.x + resized.map.restoreRect.width).toBeLessThanOrEqual(788);
|
||||
expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
export const MAX_ANALYSIS_WINDOWS = 6;
|
||||
|
||||
export type WorkspaceRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type WorkspaceBounds = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
|
||||
|
||||
export type AnalysisMetric = {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
tone: "neutral" | "info" | "success" | "warning" | "danger";
|
||||
};
|
||||
|
||||
export type AnalysisChartSeries = {
|
||||
name: string;
|
||||
values: number[];
|
||||
color: string;
|
||||
type?: "line" | "bar";
|
||||
};
|
||||
|
||||
export type AnalysisBlock =
|
||||
| {
|
||||
id: string;
|
||||
kind: "metric-grid";
|
||||
title: string;
|
||||
span: 12;
|
||||
metrics: AnalysisMetric[];
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "chart";
|
||||
title: string;
|
||||
description: string;
|
||||
span: 5 | 7 | 12;
|
||||
chartType: "line" | "bar";
|
||||
categories: string[];
|
||||
unit: string;
|
||||
series: AnalysisChartSeries[];
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "process-flow";
|
||||
title: string;
|
||||
description: string;
|
||||
span: 5 | 7 | 12;
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
status: "complete" | "active" | "pending";
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "data-table";
|
||||
title: string;
|
||||
description: string;
|
||||
span: 5 | 7 | 12;
|
||||
columns: Array<{ key: string; label: string; numeric?: boolean }>;
|
||||
rows: Array<Record<string, string | number>>;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "narrative";
|
||||
title: string;
|
||||
span: 5 | 7 | 12;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type AnalysisDocument = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
generatedAt: string;
|
||||
blocks: AnalysisBlock[];
|
||||
};
|
||||
|
||||
export type AnalysisWorkspaceWindow = {
|
||||
id: string;
|
||||
kind: "agent-analysis";
|
||||
title: string;
|
||||
mode: Exclude<WorkspaceWindowMode, "docked">;
|
||||
rect: WorkspaceRect;
|
||||
restoreRect: WorkspaceRect;
|
||||
zIndex: number;
|
||||
document: AnalysisDocument;
|
||||
};
|
||||
|
||||
export type MapWorkspaceWindow = {
|
||||
id: "workspace-map";
|
||||
kind: "map";
|
||||
title: string;
|
||||
mode: WorkspaceWindowMode;
|
||||
rect: WorkspaceRect;
|
||||
restoreRect: WorkspaceRect;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
map: MapWorkspaceWindow;
|
||||
analyses: AnalysisWorkspaceWindow[];
|
||||
activeWindowId: string;
|
||||
nextZIndex: number;
|
||||
};
|
||||
|
||||
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||
|
||||
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
|
||||
const mapRect = getDefaultMapRect(bounds);
|
||||
return {
|
||||
map: {
|
||||
id: "workspace-map",
|
||||
kind: "map",
|
||||
title: "供水管网地图",
|
||||
mode: "docked",
|
||||
rect: mapRect,
|
||||
restoreRect: mapRect,
|
||||
zIndex: 1
|
||||
},
|
||||
analyses: [],
|
||||
activeWindowId: "workspace-map",
|
||||
nextZIndex: 2
|
||||
};
|
||||
}
|
||||
|
||||
export function openAnalysisDocument(
|
||||
state: WorkspaceState,
|
||||
document: AnalysisDocument,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
|
||||
const window: AnalysisWorkspaceWindow = {
|
||||
id: document.id,
|
||||
kind: "agent-analysis",
|
||||
title: document.title,
|
||||
mode: "floating",
|
||||
rect,
|
||||
restoreRect: rect,
|
||||
zIndex: state.nextZIndex,
|
||||
document
|
||||
};
|
||||
|
||||
return {
|
||||
map: { ...state.map, mode: "minimized" },
|
||||
analyses: [...state.analyses, window],
|
||||
activeWindowId: window.id,
|
||||
nextZIndex: state.nextZIndex + 1
|
||||
};
|
||||
}
|
||||
|
||||
export function closeAnalysisWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
const analyses = state.analyses.filter((window) => window.id !== windowId);
|
||||
if (analyses.length === state.analyses.length) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (analyses.length === 0) {
|
||||
return {
|
||||
...state,
|
||||
map: { ...state.map, mode: "docked", zIndex: state.nextZIndex },
|
||||
analyses,
|
||||
activeWindowId: state.map.id,
|
||||
nextZIndex: state.nextZIndex + 1
|
||||
};
|
||||
}
|
||||
|
||||
const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0];
|
||||
return {
|
||||
...state,
|
||||
analyses,
|
||||
activeWindowId: nextActive?.id ?? state.map.id
|
||||
};
|
||||
}
|
||||
|
||||
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
return {
|
||||
...state,
|
||||
map: { ...state.map, zIndex: state.nextZIndex },
|
||||
activeWindowId: windowId,
|
||||
nextZIndex: state.nextZIndex + 1
|
||||
};
|
||||
}
|
||||
|
||||
if (!state.analyses.some((window) => window.id === windowId)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
analyses: state.analyses.map((window) =>
|
||||
window.id === windowId ? { ...window, zIndex: state.nextZIndex } : window
|
||||
),
|
||||
activeWindowId: windowId,
|
||||
nextZIndex: state.nextZIndex + 1
|
||||
};
|
||||
}
|
||||
|
||||
export function minimizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
return { ...state, map: { ...state.map, mode: "minimized" } };
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
analyses: state.analyses.map((window) =>
|
||||
window.id === windowId ? { ...window, mode: "minimized" } : window
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
return focusWorkspaceWindow(
|
||||
{ ...state, map: { ...state.map, mode: "maximized" } },
|
||||
windowId
|
||||
);
|
||||
}
|
||||
|
||||
return focusWorkspaceWindow(
|
||||
{
|
||||
...state,
|
||||
analyses: state.analyses.map((window) =>
|
||||
window.id === windowId ? { ...window, mode: "maximized" } : window
|
||||
)
|
||||
},
|
||||
windowId
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreWorkspaceWindow(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
const rect = clampWorkspaceRect(state.map.restoreRect, bounds, "map");
|
||||
return focusWorkspaceWindow(
|
||||
{ ...state, map: { ...state.map, mode: "floating", rect } },
|
||||
windowId
|
||||
);
|
||||
}
|
||||
|
||||
return focusWorkspaceWindow(
|
||||
{
|
||||
...state,
|
||||
analyses: state.analyses.map((window) =>
|
||||
window.id === windowId
|
||||
? {
|
||||
...window,
|
||||
mode: "floating",
|
||||
rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
|
||||
}
|
||||
: window
|
||||
)
|
||||
},
|
||||
windowId
|
||||
);
|
||||
}
|
||||
|
||||
export function updateWorkspaceWindowRect(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
rect: WorkspaceRect,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
const nextRect = clampWorkspaceRect(rect, bounds, "map");
|
||||
return {
|
||||
...state,
|
||||
map: { ...state.map, mode: "floating", rect: nextRect, restoreRect: nextRect }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
analyses: state.analyses.map((window) => {
|
||||
if (window.id !== windowId) return window;
|
||||
const nextRect = clampWorkspaceRect(rect, bounds, "analysis");
|
||||
return { ...window, mode: "floating", rect: nextRect, restoreRect: nextRect };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
|
||||
return {
|
||||
...state,
|
||||
map: {
|
||||
...state.map,
|
||||
rect: clampWorkspaceRect(state.map.rect, bounds, "map"),
|
||||
restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map")
|
||||
},
|
||||
analyses: state.analyses.map((window) => ({
|
||||
...window,
|
||||
rect: clampWorkspaceRect(window.rect, bounds, "analysis"),
|
||||
restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function clampWorkspaceRect(
|
||||
rect: WorkspaceRect,
|
||||
bounds: WorkspaceBounds,
|
||||
kind: "map" | "analysis"
|
||||
): WorkspaceRect {
|
||||
const minimumWidth = kind === "map" ? 360 : 640;
|
||||
const minimumHeight = kind === "map" ? 280 : 420;
|
||||
const availableWidth = Math.max(280, bounds.width - 24);
|
||||
const availableHeight = Math.max(240, bounds.height - 24);
|
||||
const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth);
|
||||
const height = clamp(rect.height, Math.min(minimumHeight, availableHeight), availableHeight);
|
||||
return {
|
||||
x: clamp(rect.x, 12, Math.max(12, bounds.width - width - 12)),
|
||||
y: clamp(rect.y, 12, Math.max(12, bounds.height - height - 12)),
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect {
|
||||
const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48));
|
||||
const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48));
|
||||
const cascade = (index % 5) * 28;
|
||||
return clampWorkspaceRect(
|
||||
{ x: 24 + cascade, y: 20 + cascade, width, height },
|
||||
bounds,
|
||||
"analysis"
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultMapRect(bounds: WorkspaceBounds): WorkspaceRect {
|
||||
return clampWorkspaceRect(
|
||||
{
|
||||
x: Math.max(12, bounds.width * 0.36),
|
||||
y: Math.max(12, bounds.height * 0.18),
|
||||
width: bounds.width * 0.6,
|
||||
height: bounds.height * 0.72
|
||||
},
|
||||
bounds,
|
||||
"map"
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
Copy,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
PanelTopClose,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
|
||||
|
||||
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
|
||||
type WorkspaceWindowProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
mode: WorkspaceWindowMode;
|
||||
rect: WorkspaceRect;
|
||||
zIndex: number;
|
||||
active: boolean;
|
||||
kind: "map" | "analysis";
|
||||
children: ReactNode;
|
||||
onFocus: () => void;
|
||||
onRectChange: (rect: WorkspaceRect) => void;
|
||||
onMinimize: () => void;
|
||||
onMaximize: () => void;
|
||||
onRestore: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function WorkspaceWindow({
|
||||
id,
|
||||
title,
|
||||
mode,
|
||||
rect,
|
||||
zIndex,
|
||||
active,
|
||||
kind,
|
||||
children,
|
||||
onFocus,
|
||||
onRectChange,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onRestore,
|
||||
onClose
|
||||
}: WorkspaceWindowProps) {
|
||||
const rectRef = useRef(rect);
|
||||
rectRef.current = rect;
|
||||
|
||||
const floating = mode === "floating";
|
||||
const style = mode === "docked"
|
||||
? { inset: 0, zIndex }
|
||||
: mode === "maximized"
|
||||
? { inset: 8, zIndex }
|
||||
: { left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex };
|
||||
|
||||
const beginPointerAction = (
|
||||
event: ReactPointerEvent,
|
||||
action: "move" | ResizeDirection
|
||||
) => {
|
||||
if (!floating || event.button !== 0) return;
|
||||
if (event.target instanceof Element && event.target.closest("button")) return;
|
||||
event.preventDefault();
|
||||
onFocus();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startRect = rectRef.current;
|
||||
|
||||
const handleMove = (pointerEvent: PointerEvent) => {
|
||||
const deltaX = pointerEvent.clientX - startX;
|
||||
const deltaY = pointerEvent.clientY - startY;
|
||||
onRectChange(resizeRect(startRect, action, deltaX, deltaY));
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
};
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleUp, { once: true });
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (!floating || !event.key.startsWith("Arrow")) return;
|
||||
event.preventDefault();
|
||||
const step = event.altKey ? 2 : 12;
|
||||
const deltaX = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
|
||||
const deltaY = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
|
||||
onRectChange(
|
||||
event.shiftKey
|
||||
? { ...rect, width: rect.width + deltaX, height: rect.height + deltaY }
|
||||
: { ...rect, x: rect.x + deltaX, y: rect.y + deltaY }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900",
|
||||
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
|
||||
mode === "docked" ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
|
||||
active ? "lg:ring-1 lg:ring-blue-500/40" : "lg:ring-1 lg:ring-slate-300/90"
|
||||
)}
|
||||
style={style}
|
||||
onPointerDownCapture={onFocus}
|
||||
>
|
||||
<header
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"workspace-window-titlebar hidden h-11 shrink-0 select-none items-center gap-2 border-b px-2 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 lg:flex",
|
||||
active ? "border-blue-200 bg-[#eaf2fb]" : "border-slate-200 bg-[#f4f6f8]",
|
||||
floating && "cursor-move"
|
||||
)}
|
||||
onDoubleClick={mode === "maximized" || mode === "docked" ? onRestore : onMaximize}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
onPointerDown={(event) => beginPointerAction(event, "move")}
|
||||
>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-md", kind === "map" ? "bg-blue-600 text-white" : "bg-slate-900 text-white")}>
|
||||
{kind === "map" ? <PanelTopClose size={15} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-slate-900">{title}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<WindowButton label={`最小化${title}`} onClick={onMinimize}><Minus size={16} aria-hidden="true" /></WindowButton>
|
||||
{mode === "maximized" || mode === "docked" ? (
|
||||
<WindowButton label={`浮动${title}`} onClick={onRestore}><Minimize2 size={15} aria-hidden="true" /></WindowButton>
|
||||
) : (
|
||||
<WindowButton label={`最大化${title}`} onClick={onMaximize}><Maximize2 size={15} aria-hidden="true" /></WindowButton>
|
||||
)}
|
||||
{onClose ? <WindowButton label={`关闭并销毁${title}`} danger onClick={onClose}><X size={16} aria-hidden="true" /></WindowButton> : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
{floating ? RESIZE_DIRECTIONS.map((direction) => (
|
||||
<span
|
||||
key={direction}
|
||||
aria-hidden="true"
|
||||
className={cn("hidden lg:block", resizeHandleClass(direction))}
|
||||
onPointerDown={(event) => beginPointerAction(event, direction)}
|
||||
/>
|
||||
)) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||
|
||||
function WindowButton({ label, danger, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"grid h-10 w-10 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
danger ? "hover:bg-rose-100 hover:text-rose-700" : "hover:bg-white hover:text-blue-700"
|
||||
)}
|
||||
onClick={(event) => { event.stopPropagation(); onClick(); }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
|
||||
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
|
||||
const west = direction.includes("w");
|
||||
const east = direction.includes("e");
|
||||
const north = direction.includes("n");
|
||||
const south = direction.includes("s");
|
||||
return {
|
||||
x: west ? rect.x + deltaX : rect.x,
|
||||
y: north ? rect.y + deltaY : rect.y,
|
||||
width: rect.width + (east ? deltaX : west ? -deltaX : 0),
|
||||
height: rect.height + (south ? deltaY : north ? -deltaY : 0)
|
||||
};
|
||||
}
|
||||
|
||||
function resizeHandleClass(direction: ResizeDirection) {
|
||||
const shared = "absolute z-20 block touch-none";
|
||||
const classes: Record<ResizeDirection, string> = {
|
||||
n: "left-2 right-2 top-0 h-2 cursor-n-resize",
|
||||
ne: "right-0 top-0 h-3 w-3 cursor-ne-resize",
|
||||
e: "bottom-2 right-0 top-2 w-2 cursor-e-resize",
|
||||
se: "bottom-0 right-0 h-3 w-3 cursor-se-resize",
|
||||
s: "bottom-0 left-2 right-2 h-2 cursor-s-resize",
|
||||
sw: "bottom-0 left-0 h-3 w-3 cursor-sw-resize",
|
||||
w: "bottom-2 left-0 top-2 w-2 cursor-w-resize",
|
||||
nw: "left-0 top-0 h-3 w-3 cursor-nw-resize"
|
||||
};
|
||||
return `${shared} ${classes[direction]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user