feat: add workspace task view

This commit is contained in:
2026-08-19 14:34:03 +08:00
parent be7b4149f2
commit bfadc95f93
6 changed files with 568 additions and 132 deletions
@@ -1,9 +1,4 @@
import {
Columns2,
Grid2X2,
Layers3,
LayoutDashboard,
LayoutTemplate,
Map as MapIcon,
PanelsTopLeft,
Sparkles
@@ -13,22 +8,13 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode
} from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { AnalysisDocumentView } from "./analysis-document-view";
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
import {
@@ -42,7 +28,6 @@ import {
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
setWorkspacePrimaryWindow,
swapWorkspaceWindows,
updateWorkspaceWindowRect,
type WorkspaceBounds,
@@ -50,6 +35,13 @@ import {
type WorkspaceRect,
type WorkspaceState
} 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 = {
@@ -75,13 +67,24 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
}, 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;
@@ -100,6 +103,19 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
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 = () => {
@@ -188,6 +204,66 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
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);
}
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
@@ -208,6 +284,16 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
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))}
@@ -222,7 +308,7 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
{mapOverlay}
</WorkspaceWindow>
{workspace.analyses.map((window) => (
{workspace.analyses.map((window, index) => (
<WorkspaceWindow
key={window.id}
id={window.id}
@@ -233,6 +319,16 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
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))}
@@ -247,6 +343,20 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
<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}
/>
) : null}
</div>
<div
@@ -254,12 +364,32 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
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={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
onClick={() => {
setTaskViewOpen(false);
setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id));
}}
/>
{workspace.analyses.map((window) => (
<TaskbarButton
@@ -268,21 +398,12 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
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))}
onClick={() => {
setTaskViewOpen(false);
setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id));
}}
/>
))}
<WorkspaceLayoutMenu
mode={workspace.layout.mode}
activeWindowTitle={getWorkspaceWindowTitle(workspace, workspace.activeWindowId)}
onLayoutChange={(mode) => {
setWorkspace((current) => applyWorkspaceLayout(current, mode, bounds));
setWorkspaceAnnouncement(`已应用${LAYOUT_LABELS[mode]}布局`);
}}
onSetPrimary={() => {
setWorkspace((current) => setWorkspacePrimaryWindow(current, current.activeWindowId, bounds));
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, workspace.activeWindowId)}已设为主窗口`);
}}
/>
<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>
@@ -309,92 +430,6 @@ function TaskbarButton({ active, minimized, icon, label, onClick }: { active: bo
);
}
const LAYOUT_LABELS: Record<WorkspaceLayoutMode, string> = {
free: "自由排列",
primary: "主次",
split: "双栏",
grid: "网格",
cascade: "层叠"
};
const LAYOUT_OPTIONS: Array<{
mode: WorkspaceLayoutMode;
description: string;
icon: typeof LayoutTemplate;
}> = [
{ mode: "primary", description: "主窗口与两个辅助窗口", icon: LayoutDashboard },
{ mode: "split", description: "两个窗口等宽并排", icon: Columns2 },
{ mode: "grid", description: "全部窗口自适应平铺", icon: Grid2X2 },
{ mode: "cascade", description: "全部窗口依次层叠", icon: Layers3 },
{ mode: "free", description: "保留当前位置手动排列", icon: PanelsTopLeft }
];
function WorkspaceLayoutMenu({
mode,
activeWindowTitle,
onLayoutChange,
onSetPrimary
}: {
mode: WorkspaceLayoutMode;
activeWindowTitle: string;
onLayoutChange: (mode: WorkspaceLayoutMode) => void;
onSetPrimary: () => void;
}) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`设置窗口布局,当前${LAYOUT_LABELS[mode]}`}
className="relative ml-1 flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-slate-700 before:absolute before:-inset-y-1 before:content-[''] 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 data-[state=open]:bg-white data-[state=open]:text-blue-700"
>
<LayoutTemplate size={15} aria-hidden="true" />
· {LAYOUT_LABELS[mode]}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="w-72 rounded-lg border-slate-200 bg-white p-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.18)] motion-reduce:animate-none"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs font-semibold text-slate-500">
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={mode} onValueChange={(value) => onLayoutChange(value as WorkspaceLayoutMode)}>
{LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuRadioItem
key={option.mode}
value={option.mode}
className="my-0.5 min-h-11 gap-2 rounded-md py-2 pl-8 pr-2 focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:bg-blue-50/80"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-slate-100 text-slate-700">
<Icon size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-semibold text-slate-900">{LAYOUT_LABELS[option.mode]}</span>
<span className="mt-0.5 block text-[11px] leading-4 text-slate-500">{option.description}</span>
</span>
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator className="my-1.5 bg-slate-200" />
<DropdownMenuItem
disabled={mode === "free"}
className="min-h-10 rounded-md px-2 text-xs font-semibold text-slate-700 focus:bg-blue-50 focus:text-blue-800"
onSelect={onSetPrimary}
>
<PanelsTopLeft size={16} aria-hidden="true" />
<span className="min-w-0 truncate">{activeWindowTitle}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
if (windowId === state.map.id) return state.map.title;
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";