feat: refactor artifact analysis workspace
This commit is contained in:
@@ -1,436 +1,109 @@
|
||||
import {
|
||||
Map as MapIcon,
|
||||
PanelsTopLeft,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { AnalysisDocumentView } from "./analysis-document-view";
|
||||
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
applyWorkspaceLayout,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
focusWorkspaceWindow,
|
||||
maximizeWorkspaceWindow,
|
||||
minimizeWorkspaceWindow,
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
swapWorkspaceWindows,
|
||||
updateWorkspaceWindowRect,
|
||||
type WorkspaceBounds,
|
||||
type WorkspaceLayoutMode,
|
||||
type WorkspaceRect,
|
||||
type WorkspaceState
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||
import type {
|
||||
AnalysisArtifact,
|
||||
ArtifactRequestedView,
|
||||
WorkbenchSurfaceMode
|
||||
} from "./workspace-model";
|
||||
import {
|
||||
getTaskViewColumnCount,
|
||||
getTaskViewPreviewRects,
|
||||
getTaskViewSourceRect,
|
||||
WORKSPACE_LAYOUT_LABELS
|
||||
} from "./workspace-task-view-layout";
|
||||
import { WorkspaceTaskView } from "./workspace-task-view";
|
||||
import { WorkspaceWindow } from "./workspace-window";
|
||||
|
||||
type WorkbenchMainFrameProps = {
|
||||
mapContent: ReactNode;
|
||||
mapOverlay?: ReactNode;
|
||||
activeArtifact: AnalysisArtifact | null;
|
||||
pendingArtifact: AnalysisArtifact | null;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
onOpenPendingArtifact: () => void;
|
||||
onDestroyPendingArtifact: () => void;
|
||||
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||
onCollapseArtifact: () => void;
|
||||
onDestroyArtifact: () => void;
|
||||
};
|
||||
|
||||
export type WorkbenchMainFrameHandle = {
|
||||
openTestAnalysis: () => void;
|
||||
};
|
||||
|
||||
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||
|
||||
type WorkspaceDragState = {
|
||||
sourceId: string;
|
||||
sourceRect: WorkspaceRect;
|
||||
targetId: string | null;
|
||||
};
|
||||
|
||||
export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, WorkbenchMainFrameProps>(function WorkbenchMainFrame({
|
||||
export function WorkbenchMainFrame({
|
||||
mapContent,
|
||||
mapOverlay
|
||||
}, ref) {
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
||||
const taskViewButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null);
|
||||
const [taskViewOpen, setTaskViewOpen] = useState(false);
|
||||
const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map");
|
||||
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
|
||||
const fixtureIndexRef = useRef(0);
|
||||
const fixtureInstanceRef = useRef(0);
|
||||
const taskViewWindows = useMemo(
|
||||
() => [workspace.map, ...workspace.analyses],
|
||||
[workspace.analyses, workspace.map]
|
||||
);
|
||||
const taskViewPreviewRects = useMemo(
|
||||
() => getTaskViewPreviewRects(taskViewWindows.length, bounds),
|
||||
[bounds, taskViewWindows.length]
|
||||
);
|
||||
|
||||
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(() => {
|
||||
if (!taskViewOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement("已关闭任务视图");
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [taskViewOpen]);
|
||||
|
||||
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]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ openTestAnalysis: openTestDocument }), [openTestDocument]);
|
||||
|
||||
const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
|
||||
const root = workspaceRef.current;
|
||||
if (!root) return null;
|
||||
return [...root.querySelectorAll<HTMLElement>("[data-workspace-window-id]")]
|
||||
.filter((element) => element.dataset.workspaceWindowId !== sourceId)
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const inset = Math.min(12, rect.width / 4, rect.height / 4);
|
||||
return rect.width > 0 && rect.height > 0
|
||||
&& pointer.x >= rect.left + inset
|
||||
&& pointer.x <= rect.right - inset
|
||||
&& pointer.y >= rect.top + inset
|
||||
&& pointer.y <= rect.bottom - inset;
|
||||
})
|
||||
.sort((a, b) => Number(b.style.zIndex || 0) - Number(a.style.zIndex || 0))[0]
|
||||
?.dataset.workspaceWindowId ?? null;
|
||||
}, []);
|
||||
|
||||
const startWindowMove = useCallback((sourceId: string, sourceRect: WorkspaceRect) => {
|
||||
const next = { sourceId, sourceRect, targetId: null };
|
||||
dragStateRef.current = next;
|
||||
setDragState(next);
|
||||
setWorkspaceAnnouncement("");
|
||||
}, []);
|
||||
|
||||
const previewWindowMove = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
|
||||
const current = dragStateRef.current;
|
||||
if (!current || current.sourceId !== sourceId) return;
|
||||
const targetId = findSwapTarget(sourceId, pointer);
|
||||
if (current.targetId === targetId) return;
|
||||
const next = { ...current, targetId };
|
||||
dragStateRef.current = next;
|
||||
setDragState(next);
|
||||
}, [findSwapTarget]);
|
||||
|
||||
const finishWindowMove = useCallback((
|
||||
sourceId: string,
|
||||
rect: WorkspaceRect,
|
||||
pointer: { x: number; y: number }
|
||||
) => {
|
||||
const current = dragStateRef.current;
|
||||
const targetId = current?.sourceId === sourceId
|
||||
? current.targetId ?? findSwapTarget(sourceId, pointer)
|
||||
: findSwapTarget(sourceId, pointer);
|
||||
if (targetId) {
|
||||
setWorkspace((state) => swapWorkspaceWindows(state, sourceId, targetId, bounds));
|
||||
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}与${getWorkspaceWindowTitle(workspace, targetId)}已交换位置`);
|
||||
} else {
|
||||
setWorkspace((state) => updateWorkspaceWindowRect(state, sourceId, rect, bounds));
|
||||
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}已转为自由排列`);
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
setDragState(null);
|
||||
}, [bounds, findSwapTarget, workspace]);
|
||||
|
||||
const cancelWindowMove = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
setDragState(null);
|
||||
setWorkspaceAnnouncement("已取消窗口移动");
|
||||
}, []);
|
||||
|
||||
function focusTaskViewWindow(windowId: string) {
|
||||
setTaskViewFocusWindowId(windowId);
|
||||
window.requestAnimationFrame(() => {
|
||||
workspaceRef.current
|
||||
?.querySelector<HTMLButtonElement>(`[data-task-view-window-id="${windowId}"]`)
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function openTaskView() {
|
||||
const windowId = workspace.activeWindowId;
|
||||
setTaskViewFocusWindowId(windowId);
|
||||
setTaskViewOpen(true);
|
||||
setWorkspaceAnnouncement(`任务视图已打开,共 ${taskViewWindows.length} 个窗口`);
|
||||
window.requestAnimationFrame(() => focusTaskViewWindow(windowId));
|
||||
}
|
||||
|
||||
function closeTaskView() {
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement("已关闭任务视图");
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
}
|
||||
|
||||
function activateTaskViewWindow(windowId: string) {
|
||||
const title = getWorkspaceWindowTitle(workspace, windowId);
|
||||
setWorkspace((current) => maximizeWorkspaceWindow(current, windowId));
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement(`${title}已铺满主视图`);
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
}
|
||||
|
||||
function applyTaskViewLayout(mode: WorkspaceLayoutMode) {
|
||||
setWorkspace((current) => applyWorkspaceLayout(
|
||||
focusWorkspaceWindow(current, taskViewFocusWindowId),
|
||||
mode,
|
||||
bounds
|
||||
));
|
||||
setTaskViewOpen(false);
|
||||
setWorkspaceAnnouncement(`已应用${WORKSPACE_LAYOUT_LABELS[mode]}布局`);
|
||||
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
|
||||
}
|
||||
|
||||
function handleTaskViewWindowKeyDown(
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
index: number
|
||||
) {
|
||||
const columns = getTaskViewColumnCount(taskViewWindows.length);
|
||||
let nextIndex = index;
|
||||
if (event.key === "ArrowLeft") nextIndex = Math.max(0, index - 1);
|
||||
else if (event.key === "ArrowRight") nextIndex = Math.min(taskViewWindows.length - 1, index + 1);
|
||||
else if (event.key === "ArrowUp") nextIndex = Math.max(0, index - columns);
|
||||
else if (event.key === "ArrowDown") nextIndex = Math.min(taskViewWindows.length - 1, index + columns);
|
||||
else if (event.key === "Home") nextIndex = 0;
|
||||
else if (event.key === "End") nextIndex = taskViewWindows.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
const nextWindow = taskViewWindows[nextIndex];
|
||||
if (nextWindow) focusTaskViewWindow(nextWindow.id);
|
||||
}
|
||||
mapOverlay,
|
||||
activeArtifact,
|
||||
pendingArtifact,
|
||||
surfaceMode,
|
||||
mapSplitAvailable,
|
||||
onOpenPendingArtifact,
|
||||
onDestroyPendingArtifact,
|
||||
onSetArtifactView,
|
||||
onCollapseArtifact,
|
||||
onDestroyArtifact
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const showMap = surfaceMode !== "focus";
|
||||
const showArtifact = activeArtifact && surfaceMode !== "map_only";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
|
||||
className="workbench-main-frame pointer-events-none absolute inset-x-0 bottom-0 top-14 lg:left-[var(--workbench-agent-current-width)]"
|
||||
data-testid="workbench-main-frame"
|
||||
>
|
||||
<div
|
||||
ref={workspaceRef}
|
||||
id="main-workspace"
|
||||
data-layout-mode={workspace.layout.mode}
|
||||
className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10"
|
||||
data-layout-mode={surfaceMode}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-0 grid min-h-0 overflow-hidden bg-[#dbe3eb]",
|
||||
surfaceMode === "map_split" && "grid-cols-2 gap-px bg-slate-300",
|
||||
surfaceMode !== "map_split" && "grid-cols-1"
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
swapTarget={dragState?.targetId === workspace.map.id}
|
||||
taskViewPreview={taskViewOpen && taskViewPreviewRects[0] ? {
|
||||
sourceRect: getTaskViewSourceRect(
|
||||
workspace.map.mode,
|
||||
workspace.map.rect,
|
||||
workspace.map.restoreRect,
|
||||
bounds
|
||||
),
|
||||
targetRect: taskViewPreviewRects[0],
|
||||
index: 0
|
||||
} : undefined}
|
||||
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, bounds))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
|
||||
onMoveStart={(rect) => startWindowMove(workspace.map.id, rect)}
|
||||
onMovePreview={(_, pointer) => previewWindowMove(workspace.map.id, pointer)}
|
||||
onMoveEnd={(rect, pointer) => finishWindowMove(workspace.map.id, rect, pointer)}
|
||||
onMoveCancel={cancelWindowMove}
|
||||
<section
|
||||
id="workspace-map"
|
||||
aria-label="供水管网地图工作区"
|
||||
aria-hidden={!showMap}
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden bg-[var(--canvas-map)]",
|
||||
!showMap && "hidden"
|
||||
)}
|
||||
>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
</WorkspaceWindow>
|
||||
{activeArtifact && surfaceMode === "map_only" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetArtifactView("focus")}
|
||||
className="pointer-events-auto absolute left-3 top-3 z-20 inline-flex h-10 items-center gap-2 rounded-md bg-slate-950 px-3 text-xs font-semibold text-white shadow-[0_8px_24px_rgba(15,23,42,0.22)] hover:bg-slate-800 active:scale-95 lg:left-4 lg:top-4"
|
||||
>
|
||||
<ArrowLeft size={15} aria-hidden="true" />
|
||||
<FileChartColumnIncreasing size={15} aria-hidden="true" />
|
||||
返回分析成果
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{workspace.analyses.map((window, index) => (
|
||||
<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"
|
||||
swapTarget={dragState?.targetId === window.id}
|
||||
taskViewPreview={taskViewOpen && taskViewPreviewRects[index + 1] ? {
|
||||
sourceRect: getTaskViewSourceRect(
|
||||
window.mode,
|
||||
window.rect,
|
||||
window.restoreRect,
|
||||
bounds
|
||||
),
|
||||
targetRect: taskViewPreviewRects[index + 1],
|
||||
index: index + 1
|
||||
} : undefined}
|
||||
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
|
||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
|
||||
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id, bounds))}
|
||||
onMoveStart={(rect) => startWindowMove(window.id, rect)}
|
||||
onMovePreview={(_, pointer) => previewWindowMove(window.id, pointer)}
|
||||
onMoveEnd={(rect, pointer) => finishWindowMove(window.id, rect, pointer)}
|
||||
onMoveCancel={cancelWindowMove}
|
||||
{activeArtifact ? (
|
||||
<section
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
className={cn("relative min-h-0 min-w-0 overflow-hidden", !showArtifact && "hidden")}
|
||||
>
|
||||
<AnalysisDocumentView document={window.document} />
|
||||
</WorkspaceWindow>
|
||||
))}
|
||||
|
||||
{taskViewOpen ? (
|
||||
<WorkspaceTaskView
|
||||
windows={taskViewWindows}
|
||||
previewRects={taskViewPreviewRects}
|
||||
focusedWindowId={taskViewFocusWindowId}
|
||||
layoutMode={workspace.layout.mode}
|
||||
onClose={closeTaskView}
|
||||
onFocusWindow={setTaskViewFocusWindowId}
|
||||
onSelectWindow={activateTaskViewWindow}
|
||||
onWindowKeyDown={handleTaskViewWindowKeyDown}
|
||||
onLayoutChange={applyTaskViewLayout}
|
||||
/>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
mapSplitAvailable={mapSplitAvailable}
|
||||
onSetView={onSetArtifactView}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
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="工作区窗口任务栏"
|
||||
>
|
||||
<button
|
||||
ref={taskViewButtonRef}
|
||||
type="button"
|
||||
aria-label="任务视图"
|
||||
aria-pressed={taskViewOpen}
|
||||
title="任务视图"
|
||||
className={cn(
|
||||
"grid h-8 w-10 shrink-0 place-items-center rounded-md transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
taskViewOpen
|
||||
? "bg-blue-600 text-white shadow-[0_1px_3px_rgba(37,99,235,0.28)]"
|
||||
: "text-slate-700 hover:bg-white"
|
||||
)}
|
||||
onClick={taskViewOpen ? closeTaskView : openTaskView}
|
||||
>
|
||||
<PanelsTopLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<span className="mx-1 h-5 w-px shrink-0 bg-slate-300" aria-hidden="true" />
|
||||
<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={() => {
|
||||
setTaskViewOpen(false);
|
||||
setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id));
|
||||
}}
|
||||
{pendingArtifact ? (
|
||||
<ArtifactPeekBar
|
||||
artifact={pendingArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
onOpen={onOpenPendingArtifact}
|
||||
onDestroy={onDestroyPendingArtifact}
|
||||
/>
|
||||
{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={() => {
|
||||
setTaskViewOpen(false);
|
||||
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>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">{workspaceAnnouncement}</span>
|
||||
{!desktop ? <span className="sr-only">移动端保持地图与抽屉工作台</span> : null}
|
||||
) : 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 getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
|
||||
if (windowId === state.map.id) return state.map.title;
|
||||
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user