feat: redesign floating workspace interactions
This commit is contained in:
@@ -42,7 +42,7 @@ export function WorkbenchAgentPanels({
|
||||
return (
|
||||
<div
|
||||
ref={resize.panelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden max-w-[720px] lg:block"
|
||||
className="pointer-events-none absolute bottom-14 left-3 top-24 z-50 hidden max-w-[720px] lg:block"
|
||||
style={{ width: resize.width ?? defaultWidth }}
|
||||
>
|
||||
{panelOpen ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
@@ -40,6 +41,7 @@ export type WorkbenchTopBarProps = {
|
||||
onCompareScenario: () => void;
|
||||
onExportScenarioReport: () => void;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
onCreateTestAnalysis?: () => void;
|
||||
onShowDataStatus: () => void;
|
||||
onRefreshTiles: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
@@ -70,6 +72,7 @@ export function WorkbenchTopBar({
|
||||
onCompareScenario,
|
||||
onExportScenarioReport,
|
||||
onAnalyzeAlertsWithAgent,
|
||||
onCreateTestAnalysis,
|
||||
onShowDataStatus,
|
||||
onRefreshTiles,
|
||||
onShowShortcuts,
|
||||
@@ -141,6 +144,23 @@ export function WorkbenchTopBar({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{onCreateTestAnalysis ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="测试打开 Agent 多图表窗口"
|
||||
title="测试打开 Agent 多图表窗口"
|
||||
data-selection-tone="soft"
|
||||
onClick={onCreateTestAnalysis}
|
||||
className={cn("group px-2", headerControlButtonClassName)}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<span className={compactHeaderTextClassName}>测试分析</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{devPanelEnabled ? (
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
|
||||
@@ -35,6 +35,7 @@ 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,
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||||
import {
|
||||
WorkbenchMainFrame,
|
||||
type WorkbenchNavigationSection
|
||||
type WorkbenchMainFrameHandle
|
||||
} from "./workspace/workbench-main-frame";
|
||||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||
@@ -103,9 +104,8 @@ export function MapWorkbenchPage({
|
||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mainFrameRef = useRef<WorkbenchMainFrameHandle | 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);
|
||||
@@ -154,6 +154,7 @@ export function MapWorkbenchPage({
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
isLargeScreen,
|
||||
leftPanelOpen,
|
||||
@@ -163,8 +164,11 @@ export function MapWorkbenchPage({
|
||||
openConditionFeedForViewport,
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setMobileSheetSnap,
|
||||
toggleConditionFeedForViewport
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
viewportWidth
|
||||
} = useWorkbenchResponsiveLayout({
|
||||
activeToolOpen: activeToolId !== null,
|
||||
agentPanelOpen: agent.panelOpen,
|
||||
@@ -175,27 +179,14 @@ export function MapWorkbenchPage({
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -208,9 +199,9 @@ export function MapWorkbenchPage({
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
mapReady,
|
||||
leftPanelOpen: isLargeScreen ? false : leftPanelOpen,
|
||||
rightPanelOpen: isLargeScreen ? devPanelOpen : rightPanelOpen,
|
||||
conditionPanelExpanded: isLargeScreen ? false : rightPanelExpanded,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
});
|
||||
|
||||
@@ -627,9 +618,9 @@ export function MapWorkbenchPage({
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(
|
||||
map,
|
||||
isLargeScreen ? false : leftPanelOpen,
|
||||
isLargeScreen ? devPanelOpen : rightPanelOpen,
|
||||
isLargeScreen ? false : rightPanelExpanded,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
),
|
||||
duration: trustedAction.durationMs ?? 500
|
||||
@@ -797,7 +788,7 @@ export function MapWorkbenchPage({
|
||||
activeScenarioId={activeScenarioId}
|
||||
alerts={workbenchAlerts}
|
||||
user={user}
|
||||
conditionFeedVisible={isLargeScreen ? mainFrameSection === "conditions" && !mainFrameNavigationCollapsed : mobileSheet === "condition"}
|
||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||
taskTickerAvailable={taskTickerAvailable}
|
||||
taskTickerVisible={taskTickerVisible}
|
||||
devPanelEnabled={devPanelEnabled}
|
||||
@@ -811,6 +802,7 @@ export function MapWorkbenchPage({
|
||||
onCompareScenario={handleCompareScenario}
|
||||
onExportScenarioReport={handleExportScenarioReport}
|
||||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||||
onCreateTestAnalysis={devPanelEnabled ? () => mainFrameRef.current?.openTestAnalysis() : undefined}
|
||||
onShowDataStatus={handleShowDataStatus}
|
||||
onRefreshTiles={handleRefreshTiles}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
@@ -819,47 +811,7 @@ export function MapWorkbenchPage({
|
||||
/>
|
||||
|
||||
<WorkbenchMainFrame
|
||||
testEnabled={devPanelEnabled}
|
||||
activeSection={mainFrameSection}
|
||||
navigationCollapsed={mainFrameNavigationCollapsed}
|
||||
onActiveSectionChange={setMainFrameSection}
|
||||
onNavigationCollapsedChange={setMainFrameNavigationCollapsed}
|
||||
renderAgentPanel={(onCollapse) => (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
presentation="desktop-dock"
|
||||
onCollapse={onCollapse}
|
||||
/>
|
||||
)}
|
||||
conditionsPanel={(
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-dock"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentWorkspaceForViewport}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
ref={mainFrameRef}
|
||||
mapContent={(
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
@@ -886,6 +838,39 @@ export function MapWorkbenchPage({
|
||||
)}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
{conditionFeedMounted && !devPanelOpen ? (
|
||||
<div className="absolute bottom-14 right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible={shouldShowConditionFeed}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={handleConditionExpandedChange}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
onSelectedConditionChange={setSelectedConditionId}
|
||||
onExpandAgent={openAgentWorkspaceForViewport}
|
||||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||||
onSubmitPrompt={agent.submitPrompt}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{devPanelEnabled && devPanelOpen ? (
|
||||
<MapDevPanel
|
||||
commands={mapController}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
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">
|
||||
<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 lg:pl-16">
|
||||
<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" />
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import {
|
||||
Bot,
|
||||
CalendarClock,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Columns2,
|
||||
FlaskConical,
|
||||
Grid2X2,
|
||||
Layers3,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
Map as MapIcon,
|
||||
PanelLeft,
|
||||
PanelsTopLeft,
|
||||
SlidersHorizontal,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
@@ -58,28 +52,14 @@ import {
|
||||
} 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 }
|
||||
];
|
||||
export type WorkbenchMainFrameHandle = {
|
||||
openTestAnalysis: () => void;
|
||||
};
|
||||
|
||||
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||
|
||||
@@ -89,19 +69,10 @@ type WorkspaceDragState = {
|
||||
targetId: string | null;
|
||||
};
|
||||
|
||||
export function WorkbenchMainFrame({
|
||||
renderAgentPanel,
|
||||
conditionsPanel,
|
||||
layersPanel,
|
||||
toolsPanel,
|
||||
export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, WorkbenchMainFrameProps>(function WorkbenchMainFrame({
|
||||
mapContent,
|
||||
mapOverlay,
|
||||
testEnabled,
|
||||
activeSection,
|
||||
navigationCollapsed,
|
||||
onActiveSectionChange,
|
||||
onNavigationCollapsedChange
|
||||
}: WorkbenchMainFrameProps) {
|
||||
mapOverlay
|
||||
}, ref) {
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||
@@ -154,6 +125,8 @@ export function WorkbenchMainFrame({
|
||||
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
|
||||
}, [bounds, workspace.analyses.length]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ openTestAnalysis: openTestDocument }), [openTestDocument]);
|
||||
|
||||
const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
|
||||
const root = workspaceRef.current;
|
||||
if (!root) return null;
|
||||
@@ -215,110 +188,16 @@ export function WorkbenchMainFrame({
|
||||
setWorkspaceAnnouncement("已取消窗口移动");
|
||||
}, []);
|
||||
|
||||
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"
|
||||
data-layout-mode={workspace.layout.mode}
|
||||
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]"
|
||||
className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10"
|
||||
>
|
||||
<WorkspaceWindow
|
||||
id={workspace.map.id}
|
||||
@@ -371,7 +250,7 @@ export function WorkbenchMainFrame({
|
||||
</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"
|
||||
className="pointer-events-auto absolute bottom-0 left-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:flex"
|
||||
role="toolbar"
|
||||
aria-label="工作区窗口任务栏"
|
||||
>
|
||||
@@ -410,7 +289,7 @@ export function WorkbenchMainFrame({
|
||||
{!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 (
|
||||
@@ -520,11 +399,3 @@ function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
|
||||
if (windowId === state.map.id) return state.map.title;
|
||||
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
|
||||
}
|
||||
|
||||
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ function visibleIds(state: WorkspaceState) {
|
||||
}
|
||||
|
||||
describe("workspace window lifecycle", () => {
|
||||
it("minimizes the map for the first analysis and restores it after the last window closes", () => {
|
||||
it("keeps the map visible under 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.map.mode).toBe("docked");
|
||||
expect(opened.analyses).toHaveLength(1);
|
||||
|
||||
const closed = closeAnalysisWindow(opened, "analysis-1");
|
||||
|
||||
@@ -174,7 +174,7 @@ export function openAnalysisDocument(
|
||||
};
|
||||
|
||||
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
|
||||
return { ...opened, map: { ...opened.map, mode: "minimized", minimizedByLayout: false } };
|
||||
return opened;
|
||||
}
|
||||
|
||||
export function closeAnalysisWindow(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
Copy,
|
||||
Grip,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
PanelTopClose,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -14,6 +13,13 @@ import {
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
|
||||
|
||||
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
@@ -85,9 +91,20 @@ export function WorkspaceWindow({
|
||||
action: "move" | ResizeDirection
|
||||
) => {
|
||||
const moving = action === "move";
|
||||
if ((!moving && !floating) || (moving && mode !== "floating" && mode !== "docked") || event.button !== 0) return;
|
||||
if (event.target instanceof Element && event.target.closest("button")) return;
|
||||
if (
|
||||
(!moving && !floating)
|
||||
|| (moving && mode !== "floating" && mode !== "docked" && mode !== "maximized")
|
||||
|| event.button !== 0
|
||||
) return;
|
||||
if (
|
||||
event.target instanceof Element
|
||||
&& (
|
||||
(!moving && event.target.closest("button"))
|
||||
|| (moving && event.target.closest("[data-window-operation]"))
|
||||
)
|
||||
) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFocus();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
@@ -105,7 +122,7 @@ export function WorkspaceWindow({
|
||||
if (!moved && Math.hypot(deltaX, deltaY) < 3) return;
|
||||
moved = true;
|
||||
if (moving) {
|
||||
const nextRect = mode === "docked" && workspaceBounds
|
||||
const nextRect = mode !== "floating" && workspaceBounds
|
||||
? {
|
||||
...startRect,
|
||||
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
|
||||
@@ -132,7 +149,7 @@ export function WorkspaceWindow({
|
||||
setDragRect(null);
|
||||
return;
|
||||
}
|
||||
const finalRect = mode === "docked" && workspaceBounds
|
||||
const finalRect = mode !== "floating" && workspaceBounds
|
||||
? {
|
||||
...startRect,
|
||||
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
|
||||
@@ -160,7 +177,7 @@ export function WorkspaceWindow({
|
||||
if (moving) window.addEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
const handleOperationKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!floating || !event.key.startsWith("Arrow")) return;
|
||||
event.preventDefault();
|
||||
const step = event.altKey ? 2 : 12;
|
||||
@@ -173,6 +190,11 @@ export function WorkspaceWindow({
|
||||
);
|
||||
};
|
||||
|
||||
const handleWindowPointerDownCapture = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
onFocus();
|
||||
if (event.altKey) beginPointerAction(event, "move");
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
@@ -180,46 +202,67 @@ export function WorkspaceWindow({
|
||||
data-workspace-window-id={id}
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none",
|
||||
"group/workspace-window pointer-events-auto absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none",
|
||||
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
|
||||
mode === "docked" && !dragRect ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
|
||||
swapTarget
|
||||
? "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
|
||||
: active
|
||||
? "lg:ring-1 lg:ring-blue-500/40"
|
||||
: "lg:ring-1 lg:ring-slate-300/90"
|
||||
(mode !== "docked" || dragRect) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
|
||||
swapTarget && "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
|
||||
)}
|
||||
style={style}
|
||||
onPointerDownCapture={onFocus}
|
||||
onPointerDownCapture={handleWindowPointerDownCapture}
|
||||
>
|
||||
<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 || mode === "docked") && "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>
|
||||
{active ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-window-operation
|
||||
aria-label={`打开${title}窗口操作`}
|
||||
title={`${title}窗口操作`}
|
||||
className="absolute left-2 top-2 z-40 hidden h-10 w-10 place-items-center rounded-md bg-slate-950/84 text-white opacity-0 shadow-[0_6px_18px_rgba(15,23,42,0.24)] transition-[opacity,background-color,scale] duration-150 [@media(hover:hover)]:group-hover/workspace-window:grid [@media(hover:hover)]:group-hover/workspace-window:opacity-100 hover:bg-slate-950 active:scale-[0.96] focus:grid focus:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 data-[state=open]:grid data-[state=open]:opacity-100 lg:grid"
|
||||
onKeyDown={handleOperationKeyDown}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Grip size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
data-window-operation
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-52 rounded-lg border-slate-200 bg-white p-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.2)] motion-reduce:animate-none"
|
||||
>
|
||||
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMinimize}>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
最小化
|
||||
</DropdownMenuItem>
|
||||
{mode === "maximized" || mode === "docked" ? (
|
||||
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onRestore}>
|
||||
<Minimize2 size={15} aria-hidden="true" />
|
||||
恢复为浮动窗口
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMaximize}>
|
||||
<Maximize2 size={15} aria-hidden="true" />
|
||||
最大化
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onClose ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1 bg-slate-200" />
|
||||
<DropdownMenuItem
|
||||
className="min-h-10 rounded-md text-xs font-semibold text-rose-700 focus:bg-rose-50 focus:text-rose-800"
|
||||
onSelect={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
关闭并销毁
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{swapTarget ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@@ -244,23 +287,6 @@ export function WorkspaceWindow({
|
||||
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user