feat(workbench): dock task and chart workspaces
This commit is contained in:
@@ -11,6 +11,7 @@ import type { InvestigationChartGroup } from "../utils/investigation-chart-group
|
||||
import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
|
||||
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
|
||||
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
|
||||
import { useChartWorkspace } from "../workspace/chart-workspace-context";
|
||||
|
||||
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
|
||||
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
|
||||
@@ -25,6 +26,7 @@ export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle,
|
||||
onCloseChart: (id: string) => void;
|
||||
references: (ids: string[]) => ReactNode;
|
||||
}) {
|
||||
const dock = useChartWorkspace();
|
||||
const entries = useMemo<ManagedChart[]>(() => [
|
||||
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
|
||||
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
|
||||
@@ -54,13 +56,13 @@ export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle,
|
||||
const count = entries.length;
|
||||
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
|
||||
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent dock={dock}
|
||||
// A session gets one automatic request. Updates never revoke a user's dismissal.
|
||||
openRequestKey={autoOpen ? "live-charts" : undefined}>
|
||||
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||
<div className="shrink-0 space-y-2 border-b border-border pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大</p></div>
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖动交换位置,支持单图放大</p></div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
|
||||
</div>
|
||||
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
History,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle
|
||||
@@ -61,6 +63,7 @@ const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||
const MINIMUM_REFRESH_FEEDBACK_MS = 3_000;
|
||||
|
||||
export type ScheduledConditionFeedPresentation =
|
||||
| "workspace"
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
@@ -82,6 +85,7 @@ type ScheduledConditionFeedProps = {
|
||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||
onExpandAgent: () => void;
|
||||
onClose?: () => void;
|
||||
reserveAgentRail?: boolean;
|
||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
|
||||
@@ -124,6 +128,7 @@ export function ScheduledConditionFeed({
|
||||
onSelectedConditionChange,
|
||||
onExpandAgent,
|
||||
onClose,
|
||||
reserveAgentRail = false,
|
||||
onLoadHistorySession,
|
||||
onSubmitPrompt,
|
||||
onOpenTaskCollaboration,
|
||||
@@ -160,8 +165,10 @@ export function ScheduledConditionFeed({
|
||||
const desktopDock = presentation === "desktop-dock";
|
||||
const compactPresentation = mobileSheet || desktopDock;
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const workspace = presentation === "workspace";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const selectedConditionId = controlledSelectedConditionId === undefined
|
||||
? uncontrolledSelectedConditionId : controlledSelectedConditionId;
|
||||
const conditionsByCategory = useMemo(
|
||||
() => groupConditionsByTaskCategory(conditions),
|
||||
[conditions]
|
||||
@@ -219,17 +226,14 @@ export function ScheduledConditionFeed({
|
||||
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
|
||||
const selectedCondition =
|
||||
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||
timelineConditions[0] ??
|
||||
null;
|
||||
const showCompactDetail = expanded && Boolean(selectedCondition);
|
||||
const focusConditionId = focusRequest?.conditionId;
|
||||
const focusRequestId = focusRequest?.requestId;
|
||||
const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null);
|
||||
const updateExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (controlledExpanded === undefined) {
|
||||
setUncontrolledExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
if (controlledExpanded === undefined) setUncontrolledExpanded(nextExpanded);
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[controlledExpanded, onExpandedChange]
|
||||
@@ -256,10 +260,7 @@ export function ScheduledConditionFeed({
|
||||
}, [taskFilter, taskFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expanded) return;
|
||||
// A controlled task selection survives panel remounts and detail-loading windows.
|
||||
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
|
||||
if (selected) {
|
||||
@@ -308,9 +309,6 @@ export function ScheduledConditionFeed({
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(
|
||||
selectedConditionId ?? timelineConditions[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
|
||||
@@ -318,9 +316,7 @@ export function ScheduledConditionFeed({
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
setStatusFilter(CONDITION_FILTER_ALL);
|
||||
setRetainedScheduledCondition(null);
|
||||
updateSelectedConditionId(
|
||||
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
|
||||
);
|
||||
updateSelectedConditionId(null);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
@@ -360,9 +356,7 @@ export function ScheduledConditionFeed({
|
||||
);
|
||||
}
|
||||
updateSelectedConditionId(conditionId);
|
||||
if (!expanded) {
|
||||
updateExpanded(true);
|
||||
}
|
||||
if (!expanded) updateExpanded(true);
|
||||
}
|
||||
|
||||
function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) {
|
||||
@@ -427,22 +421,20 @@ export function ScheduledConditionFeed({
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
data-task-category={taskCategory}
|
||||
data-presentation={presentation}
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
mobileSheet
|
||||
workspace
|
||||
? "surface-dock flex h-full w-full min-w-0 flex-col border-l border-[var(--surface-divider)] pb-16"
|
||||
: mobileSheet
|
||||
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||
: cn(
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? cn(
|
||||
"rounded-2xl",
|
||||
expanded
|
||||
? "h-[calc(100dvh-8rem)]"
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
? cn("rounded-2xl", expanded ? "h-[calc(100dvh-8rem)]" : "max-h-[calc(100dvh-8rem)]")
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded && !desktopDock
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
@@ -453,7 +445,8 @@ export function ScheduledConditionFeed({
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2.5 py-2",
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME,
|
||||
workspace && reserveAgentRail && "pl-20"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -486,18 +479,13 @@ export function ScheduledConditionFeed({
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
data-condition-dock-toggle
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? "收起工况任务" : "展开工况任务"}
|
||||
onClick={handleToggleExpanded}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center text-[var(--ink-muted)] transition hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
|
||||
"rounded-lg"
|
||||
)}
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-[var(--ink-muted)] transition-colors hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)] active:scale-95 focus-visible:outline-2 focus-visible:outline-[var(--action-blue)]"
|
||||
>
|
||||
<ChevronDown
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
/>
|
||||
{expanded ? <Minimize2 size={17} aria-hidden="true" /> : <Maximize2 size={17} aria-hidden="true" />}
|
||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||
</button>
|
||||
{mobileSheet && onClose ? (
|
||||
@@ -513,8 +501,7 @@ export function ScheduledConditionFeed({
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{expanded ? (
|
||||
<TaskCategorySwitch
|
||||
{expanded ? <TaskCategorySwitch
|
||||
activeCategory={taskCategory}
|
||||
counts={{
|
||||
scheduled: conditionsByCategory.scheduled.length,
|
||||
@@ -522,29 +509,22 @@ export function ScheduledConditionFeed({
|
||||
appointment: conditionsByCategory.appointment.length
|
||||
}}
|
||||
onChange={handleTaskCategoryChange}
|
||||
/>
|
||||
) : null}
|
||||
/> : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
compactPresentation
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
: expanded ? "scheduled-feed-layout-expanded flex-1" : "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter h-full"
|
||||
: "pointer-events-none opacity-0"
|
||||
? showCompactDetail ? "scheduled-feed-detail-enter h-full w-full" : "hidden"
|
||||
: expanded ? "scheduled-feed-detail-enter h-full" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
@@ -571,12 +551,19 @@ export function ScheduledConditionFeed({
|
||||
onReportViewed={onReportViewed}
|
||||
focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined}
|
||||
/>
|
||||
) : expanded && !compactPresentation ? (
|
||||
<div className={cn("flex h-full items-center justify-center p-6 text-center", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">请选择工况任务</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">从右侧列表选择条目后查看详情。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||
compactPresentation && (showCompactDetail ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
@@ -720,9 +707,7 @@ function ConditionTimelinePanel({
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-full max-h-none"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
: expanded ? "h-full max-h-none" : "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** Move the live panel without resetting filters, report state or pending input. */
|
||||
export function ScheduledConditionWorkspace({
|
||||
docked, dockTarget, hidden, children
|
||||
}: {
|
||||
docked: boolean;
|
||||
dockTarget: RefObject<HTMLDivElement | null>;
|
||||
hidden: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const floating = useRef<HTMLDivElement>(null);
|
||||
const restoreFocus = useRef(false);
|
||||
const [host] = useState(() => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "h-full min-h-0 min-w-0";
|
||||
return element;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const target = docked ? dockTarget.current : floating.current;
|
||||
target?.append(host);
|
||||
// Moving the host can drop keyboard focus. Restore the corresponding control.
|
||||
if (restoreFocus.current && !hidden) host.querySelector<HTMLButtonElement>("[data-condition-dock-toggle]")?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
restoreFocus.current = host.contains(document.activeElement);
|
||||
host.remove();
|
||||
};
|
||||
}, [dockTarget, docked, hidden, host]);
|
||||
|
||||
return <>
|
||||
<div ref={floating}
|
||||
className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]"
|
||||
style={hidden || docked ? { display: "none" } : undefined} />
|
||||
{createPortal(children, host)}
|
||||
</>;
|
||||
}
|
||||
@@ -92,13 +92,15 @@ async function waitForChart(
|
||||
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
|
||||
// A deliberate collapse defers rendering, rather than failing a valid command.
|
||||
if (panel?.dataset.investigationCollapsed === "true") return;
|
||||
const mapHidden = Boolean(panel?.closest('[aria-hidden="true"]'));
|
||||
const element = [
|
||||
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
|
||||
].find(
|
||||
(item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]')
|
||||
(item) => item.dataset.investigationChart === chartId && (mapHidden || !item.closest('[aria-hidden="true"]'))
|
||||
);
|
||||
if (element && element.getBoundingClientRect().height > 0) {
|
||||
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]");
|
||||
if (element && (mapHidden || element.getBoundingClientRect().height > 0)) {
|
||||
// A focused workspace deliberately hides the map's inline chart copy.
|
||||
const viewport = mapHidden ? null : panel?.querySelector<HTMLElement>("[data-investigation-content]");
|
||||
if (viewport) {
|
||||
const plot = element.querySelector("svg[role=img]") ?? element;
|
||||
const overflow =
|
||||
@@ -298,18 +300,19 @@ export function useInvestigationPresentation(options: Options) {
|
||||
update: apply,
|
||||
rendering: () => store.selectedId === null,
|
||||
cameraAllowed: () => !cameraPausedRef.current,
|
||||
waitForChart: (chartId, nextSignal) =>
|
||||
waitForChart(
|
||||
waitForChart: (chartId, nextSignal) => {
|
||||
requestChartWindow(origin);
|
||||
return waitForChart(
|
||||
chartId,
|
||||
nextSignal,
|
||||
valuesRef.current.scope ?? "investigation",
|
||||
() => store.selectedId === null
|
||||
),
|
||||
);
|
||||
},
|
||||
frame
|
||||
})
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (command.action === "open_chart") requestChartWindow(origin);
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
|
||||
@@ -50,10 +50,12 @@ describe("workbench responsive layout", () => {
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(true));
|
||||
expect(result.current.conditionFeedExpanded).toBe(true);
|
||||
expect(result.current.conditionFeedDocked).toBe(true);
|
||||
expect(collapseAgentPanel).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(false));
|
||||
expect(result.current.conditionFeedExpanded).toBe(false);
|
||||
expect(result.current.conditionFeedDocked).toBe(false);
|
||||
expect(expandAgentPanel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -76,6 +78,10 @@ describe("workbench responsive layout", () => {
|
||||
expect(result.current.mobileSheetSnap).toBe("half");
|
||||
expect(onClearActiveTool).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(true));
|
||||
expect(result.current.conditionFeedExpanded).toBe(true);
|
||||
expect(result.current.conditionFeedDocked).toBe(false);
|
||||
|
||||
act(() => result.current.toggleConditionFeedForViewport());
|
||||
expect(result.current.mobileSheet).toBeNull();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
|
||||
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
import { CONDITION_PANE_MIN_WIDTH, type ArtifactRequestedView } from "../workspace/workspace-model";
|
||||
|
||||
const CONDITION_FEED_EXIT_MS = 170;
|
||||
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
||||
@@ -33,6 +34,8 @@ export function useWorkbenchResponsiveLayout({
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [conditionFeedDocked, setConditionFeedDocked] = useState(false);
|
||||
const [conditionView, setConditionView] = useState<ArtifactRequestedView>("map_split");
|
||||
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
|
||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||||
@@ -87,7 +90,11 @@ export function useWorkbenchResponsiveLayout({
|
||||
|
||||
function openAgentPanelForViewport() {
|
||||
if (isLargeScreen) {
|
||||
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
if (conditionFeedDocked && viewportWidth - agentPanelWidth < CONDITION_PANE_MIN_WIDTH) {
|
||||
setConditionFeedDocked(false);
|
||||
setConditionFeedExpanded(false);
|
||||
}
|
||||
if (!conditionFeedDocked && conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
setConditionFeedExpanded(false);
|
||||
setAgentCollapsedForCondition(false);
|
||||
}
|
||||
@@ -100,10 +107,11 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
setAgentCollapsedForCondition(agentPanelOpen);
|
||||
collapseAgentPanel();
|
||||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||
if (isLargeScreen) {
|
||||
setConditionFeedDocked(nextExpanded);
|
||||
if (nextExpanded) setConditionView("map_split");
|
||||
}
|
||||
if (!nextExpanded && agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
expandAgentPanel();
|
||||
}
|
||||
@@ -111,22 +119,22 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||
return;
|
||||
if (isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && agentPanelOpen
|
||||
&& viewportWidth - agentPanelWidth < CONDITION_PANE_MIN_WIDTH) {
|
||||
setAgentCollapsedForCondition(true);
|
||||
collapseAgentPanel();
|
||||
}
|
||||
}, [agentPanelOpen, agentPanelWidth, collapseAgentPanel, conditionFeedDocked, isLargeScreen, shouldShowConditionFeed, viewportWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) return;
|
||||
setConditionFeedExpanded(false);
|
||||
setConditionFeedDocked(false);
|
||||
if (agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
expandAgentPanel();
|
||||
}
|
||||
}, [
|
||||
agentCollapsedForCondition,
|
||||
conditionFeedExpanded,
|
||||
expandAgentPanel,
|
||||
isLargeScreen,
|
||||
shouldShowConditionFeed
|
||||
]);
|
||||
}, [agentCollapsedForCondition, conditionFeedExpanded, expandAgentPanel, isLargeScreen, shouldShowConditionFeed]);
|
||||
|
||||
function toggleConditionFeedForViewport() {
|
||||
onClearActiveTool();
|
||||
@@ -161,14 +169,16 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
const leftPanelOpen = isLargeScreen && agentPanelOpen;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || (shouldShowConditionFeed && !conditionFeedDocked));
|
||||
const rightPanelExpanded =
|
||||
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||||
isLargeScreen && shouldShowConditionFeed && !conditionFeedDocked && conditionFeedExpanded && !devPanelOpen;
|
||||
|
||||
return {
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedDocked,
|
||||
conditionView,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
isLargeScreen,
|
||||
@@ -181,6 +191,7 @@ export function useWorkbenchResponsiveLayout({
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setConditionView,
|
||||
setMobileSheetSnap,
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
|
||||
@@ -44,6 +44,11 @@ import { InvestigationPresentationPanel } from "./components/investigation-prese
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { ScheduledConditionWorkspace } from "./components/scheduled-condition-workspace";
|
||||
import { CONDITION_MAP_SPLIT_MIN_WIDTH } from "./workspace/workspace-model";
|
||||
import { ARTIFACT_MAP_SPLIT_MIN_WIDTH, ARTIFACT_PANE_MIN_WIDTH } from "./workspace/workspace-model";
|
||||
import { ChartWorkspaceProvider } from "./workspace/chart-workspace-provider";
|
||||
import { useChartWorkspace } from "./workspace/chart-workspace-context";
|
||||
import type { InvestigationDiscussion } from "./api/investigation-report";
|
||||
import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
@@ -136,7 +141,7 @@ type MapWorkbenchPageProps = {
|
||||
projectSelection?: WorkbenchProjectSelection;
|
||||
};
|
||||
export function MapWorkbenchPage(props: MapWorkbenchPageProps) {
|
||||
return <ReadingWorkspaceProvider><MapWorkbenchContent {...props} /></ReadingWorkspaceProvider>;
|
||||
return <ReadingWorkspaceProvider><ChartWorkspaceProvider><MapWorkbenchContent {...props} /></ChartWorkspaceProvider></ReadingWorkspaceProvider>;
|
||||
}
|
||||
function MapWorkbenchContent({
|
||||
user = WORKBENCH_USER,
|
||||
@@ -145,6 +150,7 @@ function MapWorkbenchContent({
|
||||
projectSelection
|
||||
}: MapWorkbenchPageProps) {
|
||||
const readingWorkspace = useReadingWorkspace();
|
||||
const chartWorkspace = useChartWorkspace()!;
|
||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
|
||||
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
|
||||
@@ -269,8 +275,10 @@ function MapWorkbenchContent({
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedDocked,
|
||||
conditionView,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
handleConditionExpandedChange: updateConditionExpanded,
|
||||
isLargeScreen,
|
||||
leftPanelOpen,
|
||||
mobileSheet,
|
||||
@@ -281,6 +289,7 @@ function MapWorkbenchContent({
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setConditionView,
|
||||
setMobileSheetSnap,
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
@@ -295,6 +304,20 @@ function MapWorkbenchContent({
|
||||
});
|
||||
const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
|
||||
const chartCollapsedAgent = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isLargeScreen && chartWorkspace.activeId && leftPanelOpen && businessWorkspaceWidth < ARTIFACT_PANE_MIN_WIDTH) {
|
||||
chartCollapsedAgent.current = true;
|
||||
collapseAgentPanel();
|
||||
} else if (!chartWorkspace.activeId && chartCollapsedAgent.current) {
|
||||
chartCollapsedAgent.current = false;
|
||||
expandAgentPanel();
|
||||
}
|
||||
}, [businessWorkspaceWidth, chartWorkspace.activeId, collapseAgentPanel, expandAgentPanel, isLargeScreen, leftPanelOpen]);
|
||||
const handleConditionExpandedChange = useCallback((expanded: boolean) => {
|
||||
if (expanded) chartWorkspace.deactivate();
|
||||
updateConditionExpanded(expanded);
|
||||
}, [chartWorkspace, updateConditionExpanded]);
|
||||
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
|
||||
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
||||
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
|
||||
@@ -356,6 +379,7 @@ function MapWorkbenchContent({
|
||||
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
|
||||
|
||||
function openAgentWorkspaceForViewport() {
|
||||
if (!isLargeScreen || (chartWorkspace.activeId && viewportWidth - agentPanelWidth < ARTIFACT_PANE_MIN_WIDTH)) chartWorkspace.deactivate();
|
||||
openAgentPanelForViewport();
|
||||
}
|
||||
|
||||
@@ -417,10 +441,24 @@ function MapWorkbenchContent({
|
||||
}
|
||||
|
||||
function openConditionWorkspaceForViewport() {
|
||||
chartWorkspace.deactivate();
|
||||
setSelectedConditionId(null);
|
||||
setConditionFocusRequest(null);
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
|
||||
function toggleConditionWorkspaceForViewport() {
|
||||
if (chartWorkspace.activeId) {
|
||||
chartWorkspace.deactivate();
|
||||
if (!shouldShowConditionFeed) openConditionWorkspaceForViewport();
|
||||
else if (!isLargeScreen) openConditionFeedForViewport();
|
||||
handleConditionExpandedChange(true);
|
||||
return;
|
||||
}
|
||||
if (isLargeScreen ? !shouldShowConditionFeed : mobileSheet !== "condition") {
|
||||
setSelectedConditionId(null);
|
||||
setConditionFocusRequest(null);
|
||||
}
|
||||
toggleConditionFeedForViewport();
|
||||
}
|
||||
|
||||
@@ -517,8 +555,15 @@ function MapWorkbenchContent({
|
||||
: { ...padding, bottom: Math.max(padding.bottom, 420) });
|
||||
}
|
||||
});
|
||||
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
|
||||
function showMapForLocation() {
|
||||
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
|
||||
else if (chartWorkspace.activeId) chartWorkspace.setView(isLargeScreen && businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "map_only");
|
||||
else if (isLargeScreen && conditionFeedDocked) {
|
||||
setConditionView(businessWorkspaceWidth >= CONDITION_MAP_SPLIT_MIN_WIDTH ? "map_split" : "map_only");
|
||||
}
|
||||
}
|
||||
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
|
||||
showMapForLocation();
|
||||
return presentation.locateTargets(targets).then(() => true).catch((error) => {
|
||||
showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" });
|
||||
return false;
|
||||
@@ -838,6 +883,7 @@ function MapWorkbenchContent({
|
||||
|
||||
function handleOpenLatestAnalysis() {
|
||||
if (!latestAnalysisArtifact || !latestAnalysisAvailable) return;
|
||||
chartWorkspace.deactivate();
|
||||
artifactWorkspace.openArtifact(latestAnalysisArtifact);
|
||||
}
|
||||
|
||||
@@ -1285,7 +1331,7 @@ function MapWorkbenchContent({
|
||||
} : undefined,
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onLocateAnswerFeatures: async (target) => {
|
||||
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
|
||||
showMapForLocation();
|
||||
return chatPresentation.executeCommand({ action: "locate_features", layer: target.layer, feature_ids: target.feature_ids }, new AbortController().signal, "chat");
|
||||
},
|
||||
onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt,
|
||||
@@ -1294,8 +1340,10 @@ function MapWorkbenchContent({
|
||||
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
|
||||
};
|
||||
|
||||
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active);
|
||||
const mobileSheetSuspended = Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
|
||||
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active) || Boolean(chartWorkspace.activeId);
|
||||
const conditionDockTarget = useRef<HTMLDivElement>(null);
|
||||
const conditionDockActive = isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && !conditionFeedSuspended;
|
||||
const mobileSheetSuspended = Boolean(chartWorkspace.activeId) || Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -1369,6 +1417,7 @@ function MapWorkbenchContent({
|
||||
onClose={() => setAnalysisResultsPanelOpen(false)}
|
||||
getAccessToken={getAccessToken}
|
||||
onOpenArtifact={(artifact) => {
|
||||
chartWorkspace.deactivate();
|
||||
artifactWorkspace.openArtifact(artifact);
|
||||
setAnalysisResultsPanelOpen(false);
|
||||
}}
|
||||
@@ -1400,10 +1449,18 @@ function MapWorkbenchContent({
|
||||
viewportWidth={viewportWidth}
|
||||
conditionExpanded={rightPanelExpanded}
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onExpandPanel={openAgentWorkspaceForViewport}
|
||||
onWidthCommit={setAgentPanelWidth}
|
||||
>
|
||||
<WorkbenchMainFrame
|
||||
chartDock={{ window: chartWorkspace, splitAvailable: isLargeScreen && businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH, reserveAgentRail: !leftPanelOpen }}
|
||||
conditionDock={{
|
||||
active: conditionDockActive,
|
||||
splitAvailable: businessWorkspaceWidth >= CONDITION_MAP_SPLIT_MIN_WIDTH,
|
||||
target: conditionDockTarget,
|
||||
view: conditionView,
|
||||
onSetView: setConditionView
|
||||
}}
|
||||
activeArtifact={artifactWorkspace.activeArtifact}
|
||||
pendingArtifact={artifactWorkspace.pendingArtifact}
|
||||
surfaceMode={artifactWorkspace.surfaceMode}
|
||||
@@ -1523,9 +1580,10 @@ function MapWorkbenchContent({
|
||||
</WorkbenchAgentPanels>
|
||||
|
||||
{conditionFeedMounted ? (
|
||||
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={conditionFeedSuspended ? { display: "none" } : undefined}>
|
||||
<ScheduledConditionWorkspace docked={conditionDockActive} dockTarget={conditionDockTarget} hidden={conditionFeedSuspended}>
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
presentation={conditionDockActive ? "workspace" : "desktop-floating"}
|
||||
reserveAgentRail={!leftPanelOpen && (conditionView === "focus" || businessWorkspaceWidth < CONDITION_MAP_SPLIT_MIN_WIDTH)}
|
||||
conditions={scheduledConditions}
|
||||
dataSource={runtimeDataSource}
|
||||
workOrderDataSource={workOrderDataSource}
|
||||
@@ -1558,7 +1616,7 @@ function MapWorkbenchContent({
|
||||
onReportViewed={operationalTasks.markReportRead}
|
||||
onRefresh={workbenchSync.refresh}
|
||||
/>
|
||||
</div>
|
||||
</ScheduledConditionWorkspace>
|
||||
) : null}
|
||||
|
||||
{devPanelEnabled && devPanelOpen ? (
|
||||
@@ -1638,7 +1696,7 @@ function MapWorkbenchContent({
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={openAgentPanelForViewport}
|
||||
onClick={openAgentWorkspaceForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-10 w-10" state={agent.personaState} />
|
||||
@@ -1654,7 +1712,7 @@ function MapWorkbenchContent({
|
||||
type="button"
|
||||
aria-label="打开工况任务"
|
||||
title="打开工况任务"
|
||||
onClick={toggleConditionFeedForViewport}
|
||||
onClick={toggleConditionWorkspaceForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
|
||||
>
|
||||
<CalendarClock size={20} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
|
||||
|
||||
export const ChartWorkspaceContext = createContext<FloatingWindowDock | null>(null);
|
||||
export const useChartWorkspace = () => useContext(ChartWorkspaceContext);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
|
||||
import { ChartWorkspaceContext } from "./chart-workspace-context";
|
||||
|
||||
export function ChartWorkspaceProvider({ children }: { children: ReactNode }) {
|
||||
const target = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<{ activeId: string | null; view: FloatingWindowDock["view"] }>({ activeId: null, view: "map_split" });
|
||||
const activate = useCallback((id: string) => {
|
||||
setState((current) => current.activeId === id ? current : { activeId: id, view: "map_split" });
|
||||
}, []);
|
||||
const deactivate = useCallback((id?: string) => {
|
||||
setState((current) => id && current.activeId !== id ? current : { ...current, activeId: null });
|
||||
}, []);
|
||||
const setView = useCallback((view: FloatingWindowDock["view"]) => setState((current) => ({ ...current, view })), []);
|
||||
const value = useMemo(() => ({ ...state, target, activate, deactivate, setView }), [state, activate, deactivate, setView]);
|
||||
return <ChartWorkspaceContext.Provider value={value}>{children}</ChartWorkspaceContext.Provider>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export function WorkbenchContentSplit({
|
||||
split,
|
||||
showMap,
|
||||
minPaneWidth,
|
||||
minDocumentWidth = minPaneWidth,
|
||||
onMapSizeChange
|
||||
}: {
|
||||
map: ReactNode;
|
||||
@@ -16,6 +17,7 @@ export function WorkbenchContentSplit({
|
||||
split: boolean;
|
||||
showMap: boolean;
|
||||
minPaneWidth: number;
|
||||
minDocumentWidth?: number;
|
||||
onMapSizeChange: (percentage: number) => void;
|
||||
}) {
|
||||
const group = useGroupRef();
|
||||
@@ -24,7 +26,7 @@ export function WorkbenchContentSplit({
|
||||
group.current?.setLayout(
|
||||
split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 }
|
||||
);
|
||||
}, [group, split, showMap, minPaneWidth]);
|
||||
}, [group, split, showMap, minPaneWidth, minDocumentWidth]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
@@ -56,7 +58,7 @@ export function WorkbenchContentSplit({
|
||||
<ResizablePanel
|
||||
id="document"
|
||||
defaultSize={showMap ? (split ? "50%" : "0%") : "100%"}
|
||||
minSize={split ? minPaneWidth : showMap ? 0 : "100%"}
|
||||
minSize={split ? minDocumentWidth : showMap ? 0 : "100%"}
|
||||
maxSize={!split && showMap ? 0 : "100%"}
|
||||
className="relative h-full min-h-0 min-w-0"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useReadingWorkspace, type ReadingDocument } from "@/shared/ui/reading-workspace-context";
|
||||
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
|
||||
@@ -10,7 +10,8 @@ import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||
import { WorkbenchContentSplit } from "./workbench-content-split";
|
||||
import { ARTIFACT_PANE_MIN_WIDTH } from "./workspace-model";
|
||||
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
|
||||
import { ARTIFACT_PANE_MIN_WIDTH, CONDITION_PANE_MIN_WIDTH } from "./workspace-model";
|
||||
import {
|
||||
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
@@ -32,6 +33,14 @@ type WorkbenchMainFrameProps = {
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
readingSplitAvailable?: boolean;
|
||||
chartDock?: { window: FloatingWindowDock; splitAvailable: boolean; reserveAgentRail: boolean };
|
||||
conditionDock?: {
|
||||
active: boolean;
|
||||
splitAvailable: boolean;
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
view: ArtifactRequestedView;
|
||||
onSetView: (view: ArtifactRequestedView) => void;
|
||||
};
|
||||
onOpenPendingArtifact: () => void;
|
||||
onDestroyPendingArtifact: () => void;
|
||||
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||
@@ -50,6 +59,8 @@ export function WorkbenchMainFrame({
|
||||
surfaceMode: artifactSurfaceMode,
|
||||
mapSplitAvailable,
|
||||
readingSplitAvailable = false,
|
||||
chartDock,
|
||||
conditionDock,
|
||||
onOpenPendingArtifact,
|
||||
onDestroyPendingArtifact,
|
||||
onSetArtifactView,
|
||||
@@ -59,19 +70,30 @@ export function WorkbenchMainFrame({
|
||||
const reading = useReadingWorkspace();
|
||||
const lastReading = useRef<ReadingDocument | null>(null);
|
||||
useEffect(() => { if (reading?.active) lastReading.current = reading.active; }, [reading?.active]);
|
||||
const chartActive = Boolean(chartDock?.window.activeId && !reading?.active);
|
||||
const dock = reading?.active ? null : chartActive && chartDock
|
||||
? { ...chartDock.window, splitAvailable: chartDock.splitAvailable, onSetView: chartDock.window.setView }
|
||||
: conditionDock?.active ? conditionDock : null;
|
||||
const dockActive = Boolean(dock);
|
||||
const surfaceMode = reading?.active
|
||||
? reading.view === "map_split" && !readingSplitAvailable
|
||||
? "focus"
|
||||
: reading.view
|
||||
: artifactSurfaceMode;
|
||||
: dock
|
||||
? dock.view === "map_split" && !dock.splitAvailable ? "focus" : dock.view
|
||||
: artifactSurfaceMode;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const showMap = surfaceMode !== "focus";
|
||||
const showArtifact = Boolean(!reading?.active && activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean(activeArtifact && !reading?.active);
|
||||
const showArtifact = Boolean(!reading?.active && !dockActive && activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean((activeArtifact || dockActive) && !reading?.active);
|
||||
const mapOnly = surfaceMode === "map_only";
|
||||
const mapSplit = surfaceMode === "map_split";
|
||||
const [mapPercentage, setMapPercentage] = useState(50);
|
||||
const viewControlLabel = mapOnly
|
||||
const splitAvailable = dock?.splitAvailable ?? mapSplitAvailable;
|
||||
const viewControlLabel = dockActive
|
||||
? mapOnly ? chartActive ? "返回图表管理" : "返回工况任务"
|
||||
: mapSplit ? chartActive ? "专注图表" : "专注工况" : splitAvailable ? "地图对照" : "显示地图"
|
||||
: mapOnly
|
||||
? "返回分析成果"
|
||||
: mapSplit
|
||||
? "专注分析"
|
||||
@@ -94,7 +116,7 @@ export function WorkbenchMainFrame({
|
||||
<Dialog open={Boolean(reading?.active && surfaceMode === "focus")}
|
||||
onOpenChange={(open) => { if (!open) reading?.close(); }}>
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0"
|
||||
className={cn("workbench-main-frame pointer-events-none absolute inset-0 min-h-0", chartActive && "max-h-dvh")}
|
||||
data-testid="workbench-main-frame"
|
||||
data-timeline-mode={timelineMode}
|
||||
>
|
||||
@@ -107,6 +129,7 @@ export function WorkbenchMainFrame({
|
||||
split={mapSplit}
|
||||
showMap={showMap}
|
||||
minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH}
|
||||
minDocumentWidth={dockActive && !chartActive ? CONDITION_PANE_MIN_WIDTH : undefined}
|
||||
onMapSizeChange={setMapPercentage}
|
||||
map={
|
||||
<section
|
||||
@@ -124,6 +147,15 @@ export function WorkbenchMainFrame({
|
||||
}
|
||||
document={
|
||||
<>
|
||||
<div
|
||||
ref={conditionDock?.target}
|
||||
id="condition-workspace"
|
||||
className={cn("absolute inset-x-0 bottom-0 top-14 min-h-0 min-w-0", (!dockActive || chartActive || mapOnly) && "hidden")}
|
||||
/>
|
||||
<div ref={chartDock?.window.target} id="chart-workspace"
|
||||
className={cn("absolute inset-x-0 bottom-0 top-14 min-h-0 min-w-0",
|
||||
chartActive && surfaceMode === "focus" && chartDock?.reserveAgentRail && "lg:pl-[84px]",
|
||||
(!chartActive || mapOnly) && "hidden")} />
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
@@ -179,7 +211,7 @@ export function WorkbenchMainFrame({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{pendingArtifact && !reading?.active ? (
|
||||
{pendingArtifact && !reading?.active && !dockActive ? (
|
||||
<ArtifactPeekBar
|
||||
artifact={pendingArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
@@ -205,9 +237,9 @@ export function WorkbenchMainFrame({
|
||||
type="button"
|
||||
aria-label={viewControlLabel}
|
||||
onClick={() =>
|
||||
onSetArtifactView(
|
||||
(dock?.onSetView ?? onSetArtifactView)(
|
||||
surfaceMode === "focus"
|
||||
? mapSplitAvailable
|
||||
? splitAvailable
|
||||
? "map_split"
|
||||
: "map_only"
|
||||
: "focus"
|
||||
@@ -243,12 +275,12 @@ export function WorkbenchMainFrame({
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0"
|
||||
className={cn("pointer-events-auto absolute left-3 z-20 mx-auto min-h-0", dockActive && !showMap && "hidden")}
|
||||
initial={false}
|
||||
animate={{
|
||||
bottom: timelineBottom,
|
||||
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
|
||||
right: 12
|
||||
right: dockActive && mapSplit ? `calc(${100 - mapPercentage}% + 12px)` : 12
|
||||
}}
|
||||
transition={timelineTransition}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { FeatureCollection, Geometry } from "geojson";
|
||||
|
||||
export const ARTIFACT_PANE_MIN_WIDTH = 600;
|
||||
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1;
|
||||
export const CONDITION_PANE_MIN_WIDTH = 880;
|
||||
export const CONDITION_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH + CONDITION_PANE_MIN_WIDTH + 1;
|
||||
|
||||
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
|
||||
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { FloatingWindow } from "./floating-window";
|
||||
import { floatingWindowEntries, floatingWindowOrder } from "./floating-window-layer";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -35,3 +37,61 @@ it("retains manual opening, Escape dismissal and focus return", () => {
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible();
|
||||
});
|
||||
|
||||
function DockFixture({ multiple = false }: { multiple?: boolean }) {
|
||||
const target = useRef<HTMLDivElement>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activate = useCallback((id: string) => setActiveId(id), []);
|
||||
const deactivate = useCallback((id?: string) => setActiveId((current) => !id || current === id ? null : current), []);
|
||||
return <>
|
||||
<div ref={target} data-testid="workspace-slot" />
|
||||
<button onClick={() => deactivate()}>切换工作区</button>
|
||||
<FloatingWindow title="停靠分析" dock={{ target, activeId, activate, deactivate, view: "map_split", setView: () => undefined }}>
|
||||
{() => <input aria-label="分析备注" />}
|
||||
</FloatingWindow>
|
||||
{multiple ? <FloatingWindow title="停靠对比" dock={{ target, activeId, activate, deactivate, view: "map_split", setView: () => undefined }}>
|
||||
{() => <p>第二个来源</p>}
|
||||
</FloatingWindow> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
it("preserves the live document when a docked window is hidden and located again", () => {
|
||||
render(<DockFixture />);
|
||||
const trigger = screen.getByRole("button", { name: "放大查看 停靠分析" });
|
||||
fireEvent.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "停靠分析" });
|
||||
expect(screen.getByTestId("workspace-slot")).toContainElement(dialog);
|
||||
expect(dialog).toHaveAttribute("data-window-presentation", "docked");
|
||||
expect(floatingWindowEntries()).toEqual([]);
|
||||
expect(floatingWindowOrder()).toEqual([]);
|
||||
const editor = screen.getByLabelText("分析备注");
|
||||
fireEvent.change(editor, { target: { value: "保留核查说明" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换工作区" }));
|
||||
expect(dialog).not.toBeVisible();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByLabelText("分析备注")).toBe(editor);
|
||||
expect(editor).toHaveValue("保留核查说明");
|
||||
expect(dialog).toBeVisible();
|
||||
expect(floatingWindowEntries()).toEqual([]);
|
||||
expect(floatingWindowOrder()).toEqual([]);
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides inactive docking hosts so another source can occupy the same workspace", () => {
|
||||
render(<DockFixture multiple />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠分析" }));
|
||||
const first = screen.getByRole("dialog", { name: "停靠分析" });
|
||||
const editor = screen.getByLabelText("分析备注");
|
||||
fireEvent.change(editor, { target: { value: "保留第一个来源" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠对比" }));
|
||||
const second = screen.getByRole("dialog", { name: "停靠对比" });
|
||||
expect(first.parentElement).not.toBeVisible();
|
||||
expect(first.parentElement).toHaveAttribute("hidden");
|
||||
expect(second.parentElement).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠分析" }));
|
||||
expect(first.parentElement).toBeVisible();
|
||||
expect(second.parentElement).not.toBeVisible();
|
||||
expect(screen.getByLabelText("分析备注")).toBe(editor);
|
||||
expect(editor).toHaveValue("保留第一个来源");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
@@ -8,6 +8,14 @@ import { useFloatingWindowDrag } from "./use-floating-window-drag";
|
||||
import { useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
|
||||
|
||||
type Bounds = { x: number; y: number; width: number; height: number };
|
||||
export type FloatingWindowDock = {
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
activeId: string | null;
|
||||
view: "map_split" | "focus" | "map_only";
|
||||
activate: (id: string) => void;
|
||||
deactivate: (id?: string) => void;
|
||||
setView: (view: FloatingWindowDock["view"]) => void;
|
||||
};
|
||||
const resizeHandles: Record<ResizeEdge, React.CSSProperties> = {
|
||||
top: { top: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
|
||||
right: { right: 0, top: 18, bottom: 18, width: 8, cursor: "ew-resize" },
|
||||
@@ -28,7 +36,7 @@ function fitWindow(bounds: Bounds): Bounds {
|
||||
y: Math.max(top, Math.min(bounds.y, window.innerHeight - height - 8)) };
|
||||
}
|
||||
|
||||
export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, children }: {
|
||||
export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, dock, children }: {
|
||||
title: string;
|
||||
navigationTitle?: string;
|
||||
triggerLabel?: string;
|
||||
@@ -37,6 +45,8 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
fillContent?: boolean;
|
||||
/** A new display request opens the window without taking keyboard focus. */
|
||||
openRequestKey?: string;
|
||||
/** An optional layout host; individual windows remain floating by default. */
|
||||
dock?: FloatingWindowDock | null;
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
@@ -55,11 +65,23 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const heading = useRef<HTMLDivElement>(null);
|
||||
const focusOnOpen = useRef(false);
|
||||
const raise = () => raiseFloatingWindow(id);
|
||||
const [dockHost] = useState(() => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "h-full min-h-0 min-w-0";
|
||||
return element;
|
||||
});
|
||||
const docked = Boolean(dock);
|
||||
const focusWindow = () => {
|
||||
dock?.activate(id);
|
||||
if (!docked) raiseFloatingWindow(id);
|
||||
requestAnimationFrame(() => heading.current?.focus({ preventScroll: true }));
|
||||
};
|
||||
const raise = () => { if (!docked) raiseFloatingWindow(id); };
|
||||
const close = () => {
|
||||
setManuallyOpen(false);
|
||||
setRequest({ key: openRequestKey, dismissed: true });
|
||||
removeFloatingWindow(id);
|
||||
dock?.deactivate(id);
|
||||
if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement))
|
||||
trigger.current?.focus({ preventScroll: true });
|
||||
};
|
||||
@@ -67,14 +89,33 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
useLayoutEffect(() => {
|
||||
if (open) registerFloatingWindow({ id, title: navigationTitle ?? title,
|
||||
focus: () => { raiseFloatingWindow(id); heading.current?.focus({ preventScroll: true }); },
|
||||
if (open && !docked) registerFloatingWindow({ id, title: navigationTitle ?? title,
|
||||
focus: focusWindow,
|
||||
close: () => closeRef.current() });
|
||||
}, [id, open, title, navigationTitle]);
|
||||
});
|
||||
|
||||
const activateDock = dock?.activate;
|
||||
const deactivateDock = dock?.deactivate;
|
||||
const dockTarget = dock?.target;
|
||||
useLayoutEffect(() => {
|
||||
dockHost.hidden = Boolean(dock && dock.activeId !== id);
|
||||
}, [dock, dockHost, id]);
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !dockTarget) return;
|
||||
dockTarget.current?.append(dockHost);
|
||||
activateDock?.(id);
|
||||
return () => { dockHost.remove(); deactivateDock?.(id); };
|
||||
}, [id, open, dockHost, dockTarget, activateDock, deactivateDock]);
|
||||
useLayoutEffect(() => {
|
||||
if (open && focusOnOpen.current && (!dock || dock.activeId === id)) {
|
||||
heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
}
|
||||
}, [open, dock, id]);
|
||||
|
||||
useEffect(() => () => removeFloatingWindow(id), [id]);
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || docked) return;
|
||||
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
|
||||
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
|
||||
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
|
||||
@@ -89,8 +130,6 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
setBounds(fitWindow({ x: (window.innerWidth - initialSizeRef.current.width) / 2 + slot * 24, y,
|
||||
width: initialSizeRef.current.width, height: Math.min(initialSizeRef.current.height, window.innerHeight - y - 8) }));
|
||||
raiseFloatingWindow(id);
|
||||
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
const resize = () => {
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds((current) => {
|
||||
@@ -104,16 +143,16 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
window.removeEventListener("resize", resize);
|
||||
removeFloatingWindow(id);
|
||||
};
|
||||
}, [id, open]);
|
||||
}, [id, open, docked]);
|
||||
|
||||
const current = maximized
|
||||
? { x: 8, y: floatingWindowTopInset(), width: viewport.width - 16, height: viewport.height - floatingWindowTopInset() - 8 }
|
||||
: bounds;
|
||||
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
|
||||
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized && !docked, position: current,
|
||||
width: current.width, height: current.height,
|
||||
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
|
||||
|
||||
const startResize = useFloatingWindowResize({ enabled: open && !maximized, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
|
||||
const startResize = useFloatingWindowResize({ enabled: open && !maximized && !docked, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
|
||||
|
||||
return <>
|
||||
<Button ref={trigger} type="button" variant="ghost" size={triggerContent ? "sm" : "icon-sm"} className="shrink-0"
|
||||
@@ -124,24 +163,26 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
setMaximized(false);
|
||||
focusOnOpen.current = true;
|
||||
setManuallyOpen(true);
|
||||
} else heading.current?.focus({ preventScroll: true });
|
||||
} else focusWindow();
|
||||
raise();
|
||||
}}><Maximize2 size={15} aria-hidden />{triggerContent}</Button>
|
||||
{open ? createPortal(
|
||||
<div style={{ position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
|
||||
<div style={docked ? { position: "relative", width: "100%", height: "100%" } : { position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
|
||||
transform: `translate(${current.x}px, ${current.y}px)`, zIndex: order.indexOf(id) + 1 }}
|
||||
className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
|
||||
className={`workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden border border-border bg-background text-foreground ${docked ? "pb-28 lg:pb-14" : "rounded-xl shadow-lg"}`}
|
||||
role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window="" data-floating-window-id={id}
|
||||
data-window-presentation={docked ? "docked" : "floating"}
|
||||
hidden={Boolean(dock && dock.activeId !== id)}
|
||||
onPointerDownCapture={raise} onFocusCapture={raise}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
|
||||
}}>
|
||||
<div ref={heading} tabIndex={0}
|
||||
onPointerDown={startDrag}
|
||||
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
|
||||
className={`floating-window-heading flex min-h-12 shrink-0 ${docked ? "" : "cursor-move touch-none"} select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring`}
|
||||
aria-label={docked ? `${title}标题栏` : "窗口标题栏,可用方向键移动,Shift 加方向键调整大小"}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
if (docked || event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0;
|
||||
const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 16 : 0;
|
||||
@@ -151,10 +192,10 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
}}>
|
||||
<h2 id={titleId} className="min-w-0 break-words text-sm font-semibold leading-5">{title}</h2>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
|
||||
{!docked ? <Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
|
||||
title={maximized ? "还原窗口" : "最大化窗口"} onClick={() => setMaximized(!maximized)}>
|
||||
{maximized ? <Minimize2 size={15} aria-hidden /> : <Maximize2 size={15} aria-hidden />}
|
||||
</Button>
|
||||
</Button> : null}
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="关闭窗口" title="关闭窗口" onClick={close}>
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
@@ -165,13 +206,13 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
{children({ width: current.width - 26, height: current.height - 76 })}
|
||||
</div>
|
||||
</div>
|
||||
{!maximized ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
|
||||
{!maximized && !docked ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
|
||||
<div key={edge} aria-hidden data-window-resize-handle={edge}
|
||||
className={`absolute z-10 touch-none ${edge === "bottomRight" ? "floating-window-resize" : ""}`}
|
||||
style={style} onPointerDown={(event) => startResize(event, edge)}>
|
||||
{edge === "bottomRight" ? <span className="pointer-events-none absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> : null}
|
||||
</div>
|
||||
)) : null}
|
||||
</div>, floatingWindowHost()) : null}
|
||||
</div>, docked ? dockHost : floatingWindowHost()) : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user