feat(workbench): dock task and chart workspaces
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m14s

This commit is contained in:
2026-11-12 11:42:25 +08:00
parent 33f0b90a7d
commit 7a304a91a8
27 changed files with 766 additions and 201 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ SCADA 的压力、水头(m)及流量、管流(m³/h)数值轴使用对
图表发布及 Dev 示例的对象聚合、同期校验和历史读取边界统一见 [调查展示 Dev 测试](investigation-dev-tools.md#对象复合图与验证规则)。结构化 Chart 保持单图语义,对象聚合通过 open_chart 的独立对象数据集实现。 图表发布及 Dev 示例的对象聚合、同期校验和历史读取边界统一见 [调查展示 Dev 测试](investigation-dev-tools.md#对象复合图与验证规则)。结构化 Chart 保持单图语义,对象聚合通过 open_chart 的独立对象数据集实现。
[图表和窗口管理](investigation-dev-tools.md#图表和窗口管理)由浏览器宿主负责:总窗口收纳过程图组与报告 Chart,子图表保留独立弹窗放大。总窗口提供单图、左右、四宫格固定槽位,全部图表沿纵向连续排列,末行按列数补齐空槽;整条位置栏提供拖拽和悬停反馈,拖放到目标位置交换内容。窄窗口使用单图,快速定位会滚动到目标位置。同一图表更新保留位置,删图留空,新图先填空槽。顶部窗口列表可定位、置顶已打开的总窗口、子图表和表格,并统一关闭。窗口缩放期间,图表按窗口实际尺寸适配并保留图表实例与筛选状态;松开后确认最终尺寸,Escape 或指针取消恢复原尺寸。 [图表和窗口管理](investigation-dev-tools.md#图表和窗口管理)由浏览器宿主负责:管理区占用右侧分析区域,收纳过程图组与报告 Chart,子图表保留独立弹窗放大。管理区提供单图、左右、四宫格固定槽位,全部图表沿纵向连续排列,末行按列数补齐空槽;整条位置栏提供拖拽和悬停反馈,拖放到目标位置交换内容。窄区域使用单图,快速定位会滚动到目标位置。同一图表更新保留位置,删图留空,新图先填空槽。顶部窗口列表仅定位、置顶和统一关闭独立子图表与表格浮窗,不包含图表管理区。拖动分隔条或缩放独立浮窗时,图表按实际尺寸适配并保留图表实例与筛选状态;独立浮窗缩放支持 Escape 或指针取消恢复原尺寸。
任务打开、切换、刷新、已完成任务载入和历史恢复只更新内容,不弹窗;运行中新收到的 open_chart 由总窗口统一处理,关闭后的实时更新不重新弹出。排列、布局和窗口操作不新增模型输出或 wire 协议字段,也不改变图表校验与证据规则。具体自动弹出与关闭边界以上述管理说明为准。 任务打开、切换、刷新、已完成任务载入和历史恢复只更新内容,不弹窗;运行中新收到的 open_chart 由总窗口统一处理,关闭后的实时更新不重新弹出。排列、布局和窗口操作不新增模型输出或 wire 协议字段,也不改变图表校验与证据规则。具体自动弹出与关闭边界以上述管理说明为准。
+5 -3
View File
@@ -22,7 +22,9 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
## 图表和窗口管理 ## 图表和窗口管理
调查、本地预览与对话展示面板提供“图表窗口”入口,在一个可拖动、缩放、最大化的总窗口中集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭总窗口收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。 调查、本地预览与对话展示面板提供“图表窗口”入口,在右侧分析区域打开图表管理区,集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。管理区不提供浮窗拖动、边缘缩放或最大化按钮,通过工作区底部入口切换地图对照与专注图表。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭管理区收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。
图表管理区与地图分栏时,两侧最小宽度均为 600px;空间不足时显示专注图表。工况任务沿用展开按钮,展开后占用右侧分析区域,任务区最小宽度为 880px;普通打开或重新打开时默认不选择条目。切换工况任务与图表管理区保留已加载的图表、布局和筛选状态。
总窗口使用固定槽位布局,提供“单图布局”“左右布局”“四宫格布局”,每行分别显示 1、2、2 个固定位置,单图与左右布局每行约占一个可视区高度,四宫格每个可视区约显示两行。全部图表沿纵向连续排列,通过滚动查看;图表随槽位尺寸适配,末行按列数补齐空槽。窄窗口使用单图布局,调宽后恢复先前选择的布局。头部定位、布局和重置控件置于同一条浅底工具栏,控件保持等高,窄窗口允许自然换行。“快速定位图表”列出全部图表,选择后滚动到对应位置并聚焦目标;目标的位置栏短暂显示淡色背景和“已定位”,不使用整卡外框,避免高亮被滚动区域裁切。 总窗口使用固定槽位布局,提供“单图布局”“左右布局”“四宫格布局”,每行分别显示 1、2、2 个固定位置,单图与左右布局每行约占一个可视区高度,四宫格每个可视区约显示两行。全部图表沿纵向连续排列,通过滚动查看;图表随槽位尺寸适配,末行按列数补齐空槽。窄窗口使用单图布局,调宽后恢复先前选择的布局。头部定位、布局和重置控件置于同一条浅底工具栏,控件保持等高,窄窗口允许自然换行。“快速定位图表”列出全部图表,选择后滚动到对应位置并聚焦目标;目标的位置栏短暂显示淡色背景和“已定位”,不使用整卡外框,避免高亮被滚动区域裁切。
@@ -32,9 +34,9 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
槽位映射依据稳定的子图表标识:同一图表实时更新时保留位置,删除图表后保留中间空槽并移除末尾空行,新图优先填补空槽,再追加位置与行。同一次任务查看期间,关闭并重新打开总窗口保留槽位与布局;任务、项目或执行代次切换后重新建立当前内容。交换位置不改变图表数据、对象选择或服务端的展示事件顺序。 槽位映射依据稳定的子图表标识:同一图表实时更新时保留位置,删除图表后保留中间空槽并移除末尾空行,新图优先填补空槽,再追加位置与行。同一次任务查看期间,关闭并重新打开总窗口保留槽位与布局;任务、项目或执行代次切换后重新建立当前内容。交换位置不改变图表数据、对象选择或服务端的展示事件顺序。
工作台顶部的窗口列表入口列出已打开的总图表窗口、独立子图表和表格窗口,选择条目可将对应窗口置顶并定位到标题栏。列表中的“关闭全部窗口”统一收起当前打开的窗口,不删除已加载的图表、表格或任务结果;图表窗口可从原展示入口重新打开。 工作台顶部的窗口列表入口仅列出已打开的独立子图表和表格浮窗,图表管理区不计入列表和窗口数量。选择条目可将对应浮窗置顶并定位到标题栏。“关闭全部窗口”收起独立浮窗,保留图表管理区和已加载结果;图表管理区可从原展示入口重新打开。
总窗口与子图表使用同一缩放交互:指针拖动窗口边缘时,图表按窗口实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择;松开后确认最终尺寸。拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸;标题栏的 Shift 加方向键继续用于逐步调整大小。 拖动地图与分析区域的分隔条时,管理区图表随实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择。独立子图表仍支持拖动窗口边缘缩放;松开后确认最终尺寸,拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸,标题栏的 Shift 加方向键用于逐步调整大小。
只有正在跟随的运行中调查新收到 open_chart 时,才自动弹出一次总窗口,不自动弹出各子图表,不抢走键盘焦点;后续指令按 chart_id 更新其中的数据。打开、切换、刷新任务、SSE 重连补载、历史回看和回到当前都只恢复内容,已完成任务也不弹窗。用户关闭总窗口后,本次任务查看期间的后续更新不使其再次弹出;手动收起展示面板期间的新指令也不请求弹窗。总窗口已经打开时,切换历史动作或播放回看会同步更新窗口内的快照;窗口关闭时不自动打开,手动打开后显示当前回看位置。实时数据继续记录,但不覆盖正在查看的历史;“回到当前”显示最新结果。切换任务、项目或执行代次关闭原窗口并重新静默载入,返回之前看过的任务也不重放自动弹出意图。Chat 与本地 Dev 预览使用手动入口。 只有正在跟随的运行中调查新收到 open_chart 时,才自动弹出一次总窗口,不自动弹出各子图表,不抢走键盘焦点;后续指令按 chart_id 更新其中的数据。打开、切换、刷新任务、SSE 重连补载、历史回看和回到当前都只恢复内容,已完成任务也不弹窗。用户关闭总窗口后,本次任务查看期间的后续更新不使其再次弹出;手动收起展示面板期间的新指令也不请求弹窗。总窗口已经打开时,切换历史动作或播放回看会同步更新窗口内的快照;窗口关闭时不自动打开,手动打开后显示当前回看位置。实时数据继续记录,但不覆盖正在查看的历史;“回到当前”显示最新结果。切换任务、项目或执行代次关闭原窗口并重新静默载入,返回之前看过的任务也不重放自动弹出意图。Chat 与本地 Dev 预览使用手动入口。
@@ -11,6 +11,7 @@ import type { InvestigationChartGroup } from "../utils/investigation-chart-group
import { presentationChartBlocks } from "../utils/presentation-chart-blocks"; import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group"; import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot"; 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 }); 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; 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; onCloseChart: (id: string) => void;
references: (ids: string[]) => ReactNode; references: (ids: string[]) => ReactNode;
}) { }) {
const dock = useChartWorkspace();
const entries = useMemo<ManagedChart[]>(() => [ const entries = useMemo<ManagedChart[]>(() => [
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })), ...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 })) ...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; 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"> 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>} <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. // A session gets one automatic request. Updates never revoke a user's dismissal.
openRequestKey={autoOpen ? "live-charts" : undefined}> 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"> {() => <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="shrink-0 space-y-2 border-b border-border pb-2">
<div className="flex items-start justify-between gap-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> <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
</div> </div>
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1"> <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, ChevronRight,
ClipboardList, ClipboardList,
History, History,
Maximize2,
Minimize2,
RefreshCw, RefreshCw,
X, X,
XCircle XCircle
@@ -61,6 +63,7 @@ const CONDITION_CONTROL_SURFACE_CLASS_NAME =
const MINIMUM_REFRESH_FEEDBACK_MS = 3_000; const MINIMUM_REFRESH_FEEDBACK_MS = 3_000;
export type ScheduledConditionFeedPresentation = export type ScheduledConditionFeedPresentation =
| "workspace"
| "desktop-dock" | "desktop-dock"
| "desktop-floating" | "desktop-floating"
| "mobile-sheet"; | "mobile-sheet";
@@ -82,6 +85,7 @@ type ScheduledConditionFeedProps = {
onSelectedConditionChange?: (conditionId: string | null) => void; onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void; onExpandAgent: () => void;
onClose?: () => void; onClose?: () => void;
reserveAgentRail?: boolean;
onLoadHistorySession: (sessionId: string) => void | Promise<void>; onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>; onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>; onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
@@ -124,6 +128,7 @@ export function ScheduledConditionFeed({
onSelectedConditionChange, onSelectedConditionChange,
onExpandAgent, onExpandAgent,
onClose, onClose,
reserveAgentRail = false,
onLoadHistorySession, onLoadHistorySession,
onSubmitPrompt, onSubmitPrompt,
onOpenTaskCollaboration, onOpenTaskCollaboration,
@@ -160,8 +165,10 @@ export function ScheduledConditionFeed({
const desktopDock = presentation === "desktop-dock"; const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock; const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating"; const desktopFloating = presentation === "desktop-floating";
const workspace = presentation === "workspace";
const expanded = controlledExpanded ?? uncontrolledExpanded; const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId; const selectedConditionId = controlledSelectedConditionId === undefined
? uncontrolledSelectedConditionId : controlledSelectedConditionId;
const conditionsByCategory = useMemo( const conditionsByCategory = useMemo(
() => groupConditionsByTaskCategory(conditions), () => groupConditionsByTaskCategory(conditions),
[conditions] [conditions]
@@ -219,17 +226,14 @@ export function ScheduledConditionFeed({
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`; : `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
const selectedCondition = const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ?? timelineConditions.find((condition) => condition.id === selectedConditionId) ??
timelineConditions[0] ??
null; null;
const showCompactDetail = expanded && Boolean(selectedCondition);
const focusConditionId = focusRequest?.conditionId; const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId; const focusRequestId = focusRequest?.requestId;
const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null); const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null);
const updateExpanded = useCallback( const updateExpanded = useCallback(
(nextExpanded: boolean) => { (nextExpanded: boolean) => {
if (controlledExpanded === undefined) { if (controlledExpanded === undefined) setUncontrolledExpanded(nextExpanded);
setUncontrolledExpanded(nextExpanded);
}
onExpandedChange?.(nextExpanded); onExpandedChange?.(nextExpanded);
}, },
[controlledExpanded, onExpandedChange] [controlledExpanded, onExpandedChange]
@@ -256,10 +260,7 @@ export function ScheduledConditionFeed({
}, [taskFilter, taskFilterOptions]); }, [taskFilter, taskFilterOptions]);
useEffect(() => { useEffect(() => {
if (!expanded) { if (!expanded) return;
return;
}
// A controlled task selection survives panel remounts and detail-loading windows. // A controlled task selection survives panel remounts and detail-loading windows.
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId); const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
if (selected) { if (selected) {
@@ -308,9 +309,6 @@ export function ScheduledConditionFeed({
function handleToggleExpanded() { function handleToggleExpanded() {
updateExpanded(!expanded); updateExpanded(!expanded);
updateSelectedConditionId(
selectedConditionId ?? timelineConditions[0]?.id ?? null
);
} }
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) { function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
@@ -318,9 +316,7 @@ export function ScheduledConditionFeed({
setTaskFilter(CONDITION_FILTER_ALL); setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL); setStatusFilter(CONDITION_FILTER_ALL);
setRetainedScheduledCondition(null); setRetainedScheduledCondition(null);
updateSelectedConditionId( updateSelectedConditionId(null);
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
);
} }
async function handleRefresh() { async function handleRefresh() {
@@ -360,9 +356,7 @@ export function ScheduledConditionFeed({
); );
} }
updateSelectedConditionId(conditionId); updateSelectedConditionId(conditionId);
if (!expanded) { if (!expanded) updateExpanded(true);
updateExpanded(true);
}
} }
function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) { function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) {
@@ -427,22 +421,20 @@ export function ScheduledConditionFeed({
<section <section
aria-label="工况任务" aria-label="工况任务"
data-task-category={taskCategory} data-task-category={taskCategory}
data-presentation={presentation}
aria-hidden={!visible} aria-hidden={!visible}
className={cn( className={cn(
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none", "scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit", 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" ? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
: cn( : cn(
MAP_TOOL_PANEL_SURFACE_CLASS_NAME, MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
"max-w-[calc(100vw-7rem)]", "max-w-[calc(100vw-7rem)]",
desktopFloating desktopFloating
? cn( ? cn("rounded-2xl", expanded ? "h-[calc(100dvh-8rem)]" : "max-h-[calc(100dvh-8rem)]")
"rounded-2xl",
expanded
? "h-[calc(100dvh-8rem)]"
: "max-h-[calc(100dvh-8rem)]"
)
: "h-full rounded-l-2xl rounded-r-none", : "h-full rounded-l-2xl rounded-r-none",
expanded && !desktopDock expanded && !desktopDock
? "scheduled-feed-panel-expanded flex flex-col" ? "scheduled-feed-panel-expanded flex flex-col"
@@ -453,7 +445,8 @@ export function ScheduledConditionFeed({
<header <header
className={cn( className={cn(
"flex items-center gap-2.5 px-2.5 py-2", "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 <span
@@ -486,18 +479,13 @@ export function ScheduledConditionFeed({
) : null} ) : null}
<button <button
type="button" type="button"
data-condition-dock-toggle
aria-expanded={expanded} aria-expanded={expanded}
title={expanded ? "收起工况任务" : "展开工况任务"}
onClick={handleToggleExpanded} onClick={handleToggleExpanded}
className={cn( 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)]"
"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"
)}
> >
<ChevronDown {expanded ? <Minimize2 size={17} aria-hidden="true" /> : <Maximize2 size={17} aria-hidden="true" />}
size={17}
aria-hidden="true"
className={cn("transition-transform duration-150", expanded && "rotate-180")}
/>
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span> <span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
</button> </button>
{mobileSheet && onClose ? ( {mobileSheet && onClose ? (
@@ -513,8 +501,7 @@ export function ScheduledConditionFeed({
) : null} ) : null}
</header> </header>
{expanded ? ( {expanded ? <TaskCategorySwitch
<TaskCategorySwitch
activeCategory={taskCategory} activeCategory={taskCategory}
counts={{ counts={{
scheduled: conditionsByCategory.scheduled.length, scheduled: conditionsByCategory.scheduled.length,
@@ -522,29 +509,22 @@ export function ScheduledConditionFeed({
appointment: conditionsByCategory.appointment.length appointment: conditionsByCategory.appointment.length
}} }}
onChange={handleTaskCategoryChange} onChange={handleTaskCategoryChange}
/> /> : null}
) : null}
<div <div
className={cn( className={cn(
"scheduled-feed-layout mt-3 grid min-h-0", "scheduled-feed-layout mt-3 grid min-h-0",
compactPresentation compactPresentation
? "flex flex-1" ? "flex flex-1"
: expanded : expanded ? "scheduled-feed-layout-expanded flex-1" : "scheduled-feed-layout-collapsed"
? "scheduled-feed-layout-expanded flex-1"
: "scheduled-feed-layout-collapsed"
)} )}
> >
<div <div
className={cn( className={cn(
"relative min-h-0 min-w-0 overflow-hidden", "relative min-h-0 min-w-0 overflow-hidden",
compactPresentation compactPresentation
? expanded ? showCompactDetail ? "scheduled-feed-detail-enter h-full w-full" : "hidden"
? "scheduled-feed-detail-enter h-full w-full" : expanded ? "scheduled-feed-detail-enter h-full" : "pointer-events-none opacity-0"
: "hidden"
: expanded
? "scheduled-feed-detail-enter h-full"
: "pointer-events-none opacity-0"
)} )}
aria-hidden={!expanded} aria-hidden={!expanded}
> >
@@ -571,12 +551,19 @@ export function ScheduledConditionFeed({
onReportViewed={onReportViewed} onReportViewed={onReportViewed}
focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined} 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} ) : null}
</div> </div>
<div <div
className={cn( className={cn(
"min-h-0 min-w-0 overflow-hidden", "min-h-0 min-w-0 overflow-hidden",
compactPresentation && (expanded ? "hidden" : "h-full w-full") compactPresentation && (showCompactDetail ? "hidden" : "h-full w-full")
)} )}
> >
<ConditionTimelinePanel <ConditionTimelinePanel
@@ -720,9 +707,7 @@ function ConditionTimelinePanel({
"flex min-h-0 flex-col overflow-hidden p-2.5", "flex min-h-0 flex-col overflow-hidden p-2.5",
presentation === "mobile-sheet" presentation === "mobile-sheet"
? "h-full max-h-none" ? "h-full max-h-none"
: expanded : expanded ? "h-full max-h-none" : "h-[420px] max-h-[420px]",
? "h-full max-h-none"
: "h-[420px] max-h-[420px]",
CONDITION_CONTENT_SURFACE_CLASS_NAME 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}"]`); const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
// A deliberate collapse defers rendering, rather than failing a valid command. // A deliberate collapse defers rendering, rather than failing a valid command.
if (panel?.dataset.investigationCollapsed === "true") return; if (panel?.dataset.investigationCollapsed === "true") return;
const mapHidden = Boolean(panel?.closest('[aria-hidden="true"]'));
const element = [ const element = [
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? []) ...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
].find( ].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) { if (element && (mapHidden || element.getBoundingClientRect().height > 0)) {
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]"); // A focused workspace deliberately hides the map's inline chart copy.
const viewport = mapHidden ? null : panel?.querySelector<HTMLElement>("[data-investigation-content]");
if (viewport) { if (viewport) {
const plot = element.querySelector("svg[role=img]") ?? element; const plot = element.querySelector("svg[role=img]") ?? element;
const overflow = const overflow =
@@ -298,18 +300,19 @@ export function useInvestigationPresentation(options: Options) {
update: apply, update: apply,
rendering: () => store.selectedId === null, rendering: () => store.selectedId === null,
cameraAllowed: () => !cameraPausedRef.current, cameraAllowed: () => !cameraPausedRef.current,
waitForChart: (chartId, nextSignal) => waitForChart: (chartId, nextSignal) => {
waitForChart( requestChartWindow(origin);
return waitForChart(
chartId, chartId,
nextSignal, nextSignal,
valuesRef.current.scope ?? "investigation", valuesRef.current.scope ?? "investigation",
() => store.selectedId === null () => store.selectedId === null
), );
},
frame frame
}) })
); );
signal.throwIfAborted(); signal.throwIfAborted();
if (command.action === "open_chart") requestChartWindow(origin);
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry); if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
publish(); publish();
setActivity((current) => setActivity((current) =>
@@ -50,10 +50,12 @@ describe("workbench responsive layout", () => {
act(() => result.current.handleConditionExpandedChange(true)); act(() => result.current.handleConditionExpandedChange(true));
expect(result.current.conditionFeedExpanded).toBe(true); expect(result.current.conditionFeedExpanded).toBe(true);
expect(result.current.conditionFeedDocked).toBe(true);
expect(collapseAgentPanel).toHaveBeenCalledOnce(); expect(collapseAgentPanel).toHaveBeenCalledOnce();
act(() => result.current.handleConditionExpandedChange(false)); act(() => result.current.handleConditionExpandedChange(false));
expect(result.current.conditionFeedExpanded).toBe(false); expect(result.current.conditionFeedExpanded).toBe(false);
expect(result.current.conditionFeedDocked).toBe(false);
expect(expandAgentPanel).toHaveBeenCalledOnce(); expect(expandAgentPanel).toHaveBeenCalledOnce();
}); });
@@ -76,6 +78,10 @@ describe("workbench responsive layout", () => {
expect(result.current.mobileSheetSnap).toBe("half"); expect(result.current.mobileSheetSnap).toBe("half");
expect(onClearActiveTool).toHaveBeenCalledOnce(); expect(onClearActiveTool).toHaveBeenCalledOnce();
act(() => result.current.handleConditionExpandedChange(true));
expect(result.current.conditionFeedExpanded).toBe(true);
expect(result.current.conditionFeedDocked).toBe(false);
act(() => result.current.toggleConditionFeedForViewport()); act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBeNull(); expect(result.current.mobileSheet).toBeNull();
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet"; import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout"; 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_EXIT_MS = 170;
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440; const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
@@ -33,6 +34,8 @@ export function useWorkbenchResponsiveLayout({
const [conditionFeedVisible, setConditionFeedVisible] = useState(false); const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(false); const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
const [conditionFeedExpanded, setConditionFeedExpanded] = 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 [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half"); const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false); const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
@@ -87,7 +90,11 @@ export function useWorkbenchResponsiveLayout({
function openAgentPanelForViewport() { function openAgentPanelForViewport() {
if (isLargeScreen) { 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); setConditionFeedExpanded(false);
setAgentCollapsedForCondition(false); setAgentCollapsedForCondition(false);
} }
@@ -100,10 +107,11 @@ export function useWorkbenchResponsiveLayout({
} }
function handleConditionExpandedChange(nextExpanded: boolean) { function handleConditionExpandedChange(nextExpanded: boolean) {
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) { if (isLargeScreen) {
setAgentCollapsedForCondition(agentPanelOpen); setConditionFeedDocked(nextExpanded);
collapseAgentPanel(); if (nextExpanded) setConditionView("map_split");
} else if (!nextExpanded && agentCollapsedForCondition) { }
if (!nextExpanded && agentCollapsedForCondition) {
setAgentCollapsedForCondition(false); setAgentCollapsedForCondition(false);
expandAgentPanel(); expandAgentPanel();
} }
@@ -111,22 +119,22 @@ export function useWorkbenchResponsiveLayout({
} }
useEffect(() => { useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) { if (isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && agentPanelOpen
return; && viewportWidth - agentPanelWidth < CONDITION_PANE_MIN_WIDTH) {
setAgentCollapsedForCondition(true);
collapseAgentPanel();
} }
}, [agentPanelOpen, agentPanelWidth, collapseAgentPanel, conditionFeedDocked, isLargeScreen, shouldShowConditionFeed, viewportWidth]);
useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) return;
setConditionFeedExpanded(false); setConditionFeedExpanded(false);
setConditionFeedDocked(false);
if (agentCollapsedForCondition) { if (agentCollapsedForCondition) {
setAgentCollapsedForCondition(false); setAgentCollapsedForCondition(false);
expandAgentPanel(); expandAgentPanel();
} }
}, [ }, [agentCollapsedForCondition, conditionFeedExpanded, expandAgentPanel, isLargeScreen, shouldShowConditionFeed]);
agentCollapsedForCondition,
conditionFeedExpanded,
expandAgentPanel,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() { function toggleConditionFeedForViewport() {
onClearActiveTool(); onClearActiveTool();
@@ -161,14 +169,16 @@ export function useWorkbenchResponsiveLayout({
} }
const leftPanelOpen = isLargeScreen && agentPanelOpen; const leftPanelOpen = isLargeScreen && agentPanelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed); const rightPanelOpen = isLargeScreen && (devPanelOpen || (shouldShowConditionFeed && !conditionFeedDocked));
const rightPanelExpanded = const rightPanelExpanded =
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen; isLargeScreen && shouldShowConditionFeed && !conditionFeedDocked && conditionFeedExpanded && !devPanelOpen;
return { return {
agentPanelWidth, agentPanelWidth,
closeMobileSheet, closeMobileSheet,
conditionFeedExpanded, conditionFeedExpanded,
conditionFeedDocked,
conditionView,
conditionFeedMounted, conditionFeedMounted,
handleConditionExpandedChange, handleConditionExpandedChange,
isLargeScreen, isLargeScreen,
@@ -181,6 +191,7 @@ export function useWorkbenchResponsiveLayout({
rightPanelExpanded, rightPanelExpanded,
rightPanelOpen, rightPanelOpen,
setAgentPanelWidth, setAgentPanelWidth,
setConditionView,
setMobileSheetSnap, setMobileSheetSnap,
shouldShowConditionFeed, shouldShowConditionFeed,
toggleConditionFeedForViewport, toggleConditionFeedForViewport,
+70 -12
View File
@@ -44,6 +44,11 @@ import { InvestigationPresentationPanel } from "./components/investigation-prese
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover"; import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; 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 type { InvestigationDiscussion } from "./api/investigation-report";
import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client"; import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
@@ -136,7 +141,7 @@ type MapWorkbenchPageProps = {
projectSelection?: WorkbenchProjectSelection; projectSelection?: WorkbenchProjectSelection;
}; };
export function MapWorkbenchPage(props: MapWorkbenchPageProps) { export function MapWorkbenchPage(props: MapWorkbenchPageProps) {
return <ReadingWorkspaceProvider><MapWorkbenchContent {...props} /></ReadingWorkspaceProvider>; return <ReadingWorkspaceProvider><ChartWorkspaceProvider><MapWorkbenchContent {...props} /></ChartWorkspaceProvider></ReadingWorkspaceProvider>;
} }
function MapWorkbenchContent({ function MapWorkbenchContent({
user = WORKBENCH_USER, user = WORKBENCH_USER,
@@ -145,6 +150,7 @@ function MapWorkbenchContent({
projectSelection projectSelection
}: MapWorkbenchPageProps) { }: MapWorkbenchPageProps) {
const readingWorkspace = useReadingWorkspace(); const readingWorkspace = useReadingWorkspace();
const chartWorkspace = useChartWorkspace()!;
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN); const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken; const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
@@ -269,8 +275,10 @@ function MapWorkbenchContent({
agentPanelWidth, agentPanelWidth,
closeMobileSheet, closeMobileSheet,
conditionFeedExpanded, conditionFeedExpanded,
conditionFeedDocked,
conditionView,
conditionFeedMounted, conditionFeedMounted,
handleConditionExpandedChange, handleConditionExpandedChange: updateConditionExpanded,
isLargeScreen, isLargeScreen,
leftPanelOpen, leftPanelOpen,
mobileSheet, mobileSheet,
@@ -281,6 +289,7 @@ function MapWorkbenchContent({
rightPanelExpanded, rightPanelExpanded,
rightPanelOpen, rightPanelOpen,
setAgentPanelWidth, setAgentPanelWidth,
setConditionView,
setMobileSheetSnap, setMobileSheetSnap,
shouldShowConditionFeed, shouldShowConditionFeed,
toggleConditionFeedForViewport, toggleConditionFeedForViewport,
@@ -295,6 +304,20 @@ function MapWorkbenchContent({
}); });
const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth; const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth); 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 artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null; const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null; const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
@@ -356,6 +379,7 @@ function MapWorkbenchContent({
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]); }, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() { function openAgentWorkspaceForViewport() {
if (!isLargeScreen || (chartWorkspace.activeId && viewportWidth - agentPanelWidth < ARTIFACT_PANE_MIN_WIDTH)) chartWorkspace.deactivate();
openAgentPanelForViewport(); openAgentPanelForViewport();
} }
@@ -417,10 +441,24 @@ function MapWorkbenchContent({
} }
function openConditionWorkspaceForViewport() { function openConditionWorkspaceForViewport() {
chartWorkspace.deactivate();
setSelectedConditionId(null);
setConditionFocusRequest(null);
openConditionFeedForViewport(); openConditionFeedForViewport();
} }
function toggleConditionWorkspaceForViewport() { 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(); toggleConditionFeedForViewport();
} }
@@ -517,8 +555,15 @@ function MapWorkbenchContent({
: { ...padding, bottom: Math.max(padding.bottom, 420) }); : { ...padding, bottom: Math.max(padding.bottom, 420) });
} }
}); });
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) { function showMapForLocation() {
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only"); 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) => { return presentation.locateTargets(targets).then(() => true).catch((error) => {
showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" }); showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" });
return false; return false;
@@ -838,6 +883,7 @@ function MapWorkbenchContent({
function handleOpenLatestAnalysis() { function handleOpenLatestAnalysis() {
if (!latestAnalysisArtifact || !latestAnalysisAvailable) return; if (!latestAnalysisArtifact || !latestAnalysisAvailable) return;
chartWorkspace.deactivate();
artifactWorkspace.openArtifact(latestAnalysisArtifact); artifactWorkspace.openArtifact(latestAnalysisArtifact);
} }
@@ -1285,7 +1331,7 @@ function MapWorkbenchContent({
} : undefined, } : undefined,
onSubmitPrompt: agent.submitPrompt, onSubmitPrompt: agent.submitPrompt,
onLocateAnswerFeatures: async (target) => { 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"); 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, onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt,
@@ -1294,8 +1340,10 @@ function MapWorkbenchContent({
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
}; };
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active); const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active) || Boolean(chartWorkspace.activeId);
const mobileSheetSuspended = Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen); const conditionDockTarget = useRef<HTMLDivElement>(null);
const conditionDockActive = isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && !conditionFeedSuspended;
const mobileSheetSuspended = Boolean(chartWorkspace.activeId) || Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
return ( return (
<main <main
@@ -1369,6 +1417,7 @@ function MapWorkbenchContent({
onClose={() => setAnalysisResultsPanelOpen(false)} onClose={() => setAnalysisResultsPanelOpen(false)}
getAccessToken={getAccessToken} getAccessToken={getAccessToken}
onOpenArtifact={(artifact) => { onOpenArtifact={(artifact) => {
chartWorkspace.deactivate();
artifactWorkspace.openArtifact(artifact); artifactWorkspace.openArtifact(artifact);
setAnalysisResultsPanelOpen(false); setAnalysisResultsPanelOpen(false);
}} }}
@@ -1400,10 +1449,18 @@ function MapWorkbenchContent({
viewportWidth={viewportWidth} viewportWidth={viewportWidth}
conditionExpanded={rightPanelExpanded} conditionExpanded={rightPanelExpanded}
onCollapsePanel={agent.collapsePanel} onCollapsePanel={agent.collapsePanel}
onExpandPanel={agent.expandPanel} onExpandPanel={openAgentWorkspaceForViewport}
onWidthCommit={setAgentPanelWidth} onWidthCommit={setAgentPanelWidth}
> >
<WorkbenchMainFrame <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} activeArtifact={artifactWorkspace.activeArtifact}
pendingArtifact={artifactWorkspace.pendingArtifact} pendingArtifact={artifactWorkspace.pendingArtifact}
surfaceMode={artifactWorkspace.surfaceMode} surfaceMode={artifactWorkspace.surfaceMode}
@@ -1523,9 +1580,10 @@ function MapWorkbenchContent({
</WorkbenchAgentPanels> </WorkbenchAgentPanels>
{conditionFeedMounted ? ( {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 <ScheduledConditionFeed
presentation="desktop-floating" presentation={conditionDockActive ? "workspace" : "desktop-floating"}
reserveAgentRail={!leftPanelOpen && (conditionView === "focus" || businessWorkspaceWidth < CONDITION_MAP_SPLIT_MIN_WIDTH)}
conditions={scheduledConditions} conditions={scheduledConditions}
dataSource={runtimeDataSource} dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource} workOrderDataSource={workOrderDataSource}
@@ -1558,7 +1616,7 @@ function MapWorkbenchContent({
onReportViewed={operationalTasks.markReportRead} onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh} onRefresh={workbenchSync.refresh}
/> />
</div> </ScheduledConditionWorkspace>
) : null} ) : null}
{devPanelEnabled && devPanelOpen ? ( {devPanelEnabled && devPanelOpen ? (
@@ -1638,7 +1696,7 @@ function MapWorkbenchContent({
type="button" type="button"
aria-label="打开 Agent 面板" aria-label="打开 Agent 面板"
title="打开 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" 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} /> <AgentPersona className="pointer-events-none h-10 w-10" state={agent.personaState} />
@@ -1654,7 +1712,7 @@ function MapWorkbenchContent({
type="button" type="button"
aria-label="打开工况任务" aria-label="打开工况任务"
title="打开工况任务" title="打开工况任务"
onClick={toggleConditionFeedForViewport} onClick={toggleConditionWorkspaceForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700" 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" /> <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, split,
showMap, showMap,
minPaneWidth, minPaneWidth,
minDocumentWidth = minPaneWidth,
onMapSizeChange onMapSizeChange
}: { }: {
map: ReactNode; map: ReactNode;
@@ -16,6 +17,7 @@ export function WorkbenchContentSplit({
split: boolean; split: boolean;
showMap: boolean; showMap: boolean;
minPaneWidth: number; minPaneWidth: number;
minDocumentWidth?: number;
onMapSizeChange: (percentage: number) => void; onMapSizeChange: (percentage: number) => void;
}) { }) {
const group = useGroupRef(); const group = useGroupRef();
@@ -24,7 +26,7 @@ export function WorkbenchContentSplit({
group.current?.setLayout( group.current?.setLayout(
split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 } split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 }
); );
}, [group, split, showMap, minPaneWidth]); }, [group, split, showMap, minPaneWidth, minDocumentWidth]);
return ( return (
<ResizablePanelGroup <ResizablePanelGroup
@@ -56,7 +58,7 @@ export function WorkbenchContentSplit({
<ResizablePanel <ResizablePanel
id="document" id="document"
defaultSize={showMap ? (split ? "50%" : "0%") : "100%"} defaultSize={showMap ? (split ? "50%" : "0%") : "100%"}
minSize={split ? minPaneWidth : showMap ? 0 : "100%"} minSize={split ? minDocumentWidth : showMap ? 0 : "100%"}
maxSize={!split && showMap ? 0 : "100%"} maxSize={!split && showMap ? 0 : "100%"}
className="relative h-full min-h-0 min-w-0" 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 { useReadingWorkspace, type ReadingDocument } from "@/shared/ui/reading-workspace-context";
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react"; import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/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 { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; 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 { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar"; import { ArtifactPeekBar } from "./artifact-peek-bar";
import { WorkbenchContentSplit } from "./workbench-content-split"; 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 { import {
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME, ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
@@ -32,6 +33,14 @@ type WorkbenchMainFrameProps = {
surfaceMode: WorkbenchSurfaceMode; surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean; mapSplitAvailable: boolean;
readingSplitAvailable?: 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; onOpenPendingArtifact: () => void;
onDestroyPendingArtifact: () => void; onDestroyPendingArtifact: () => void;
onSetArtifactView: (view: ArtifactRequestedView) => void; onSetArtifactView: (view: ArtifactRequestedView) => void;
@@ -50,6 +59,8 @@ export function WorkbenchMainFrame({
surfaceMode: artifactSurfaceMode, surfaceMode: artifactSurfaceMode,
mapSplitAvailable, mapSplitAvailable,
readingSplitAvailable = false, readingSplitAvailable = false,
chartDock,
conditionDock,
onOpenPendingArtifact, onOpenPendingArtifact,
onDestroyPendingArtifact, onDestroyPendingArtifact,
onSetArtifactView, onSetArtifactView,
@@ -59,19 +70,30 @@ export function WorkbenchMainFrame({
const reading = useReadingWorkspace(); const reading = useReadingWorkspace();
const lastReading = useRef<ReadingDocument | null>(null); const lastReading = useRef<ReadingDocument | null>(null);
useEffect(() => { if (reading?.active) lastReading.current = reading.active; }, [reading?.active]); 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 const surfaceMode = reading?.active
? reading.view === "map_split" && !readingSplitAvailable ? reading.view === "map_split" && !readingSplitAvailable
? "focus" ? "focus"
: reading.view : reading.view
: dock
? dock.view === "map_split" && !dock.splitAvailable ? "focus" : dock.view
: artifactSurfaceMode; : artifactSurfaceMode;
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus"; const showMap = surfaceMode !== "focus";
const showArtifact = Boolean(!reading?.active && activeArtifact && surfaceMode !== "map_only"); const showArtifact = Boolean(!reading?.active && !dockActive && activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(activeArtifact && !reading?.active); const showArtifactViewControl = Boolean((activeArtifact || dockActive) && !reading?.active);
const mapOnly = surfaceMode === "map_only"; const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split"; const mapSplit = surfaceMode === "map_split";
const [mapPercentage, setMapPercentage] = useState(50); const [mapPercentage, setMapPercentage] = useState(50);
const viewControlLabel = mapOnly const splitAvailable = dock?.splitAvailable ?? mapSplitAvailable;
const viewControlLabel = dockActive
? mapOnly ? chartActive ? "返回图表管理" : "返回工况任务"
: mapSplit ? chartActive ? "专注图表" : "专注工况" : splitAvailable ? "地图对照" : "显示地图"
: mapOnly
? "返回分析成果" ? "返回分析成果"
: mapSplit : mapSplit
? "专注分析" ? "专注分析"
@@ -94,7 +116,7 @@ export function WorkbenchMainFrame({
<Dialog open={Boolean(reading?.active && surfaceMode === "focus")} <Dialog open={Boolean(reading?.active && surfaceMode === "focus")}
onOpenChange={(open) => { if (!open) reading?.close(); }}> onOpenChange={(open) => { if (!open) reading?.close(); }}>
<div <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-testid="workbench-main-frame"
data-timeline-mode={timelineMode} data-timeline-mode={timelineMode}
> >
@@ -107,6 +129,7 @@ export function WorkbenchMainFrame({
split={mapSplit} split={mapSplit}
showMap={showMap} showMap={showMap}
minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH} minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH}
minDocumentWidth={dockActive && !chartActive ? CONDITION_PANE_MIN_WIDTH : undefined}
onMapSizeChange={setMapPercentage} onMapSizeChange={setMapPercentage}
map={ map={
<section <section
@@ -124,6 +147,15 @@ export function WorkbenchMainFrame({
} }
document={ 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"> <AnimatePresence initial={false} mode="popLayout">
{activeArtifact ? ( {activeArtifact ? (
<motion.section <motion.section
@@ -179,7 +211,7 @@ export function WorkbenchMainFrame({
</Button> </Button>
) : null} ) : null}
{pendingArtifact && !reading?.active ? ( {pendingArtifact && !reading?.active && !dockActive ? (
<ArtifactPeekBar <ArtifactPeekBar
artifact={pendingArtifact} artifact={pendingArtifact}
surfaceMode={surfaceMode} surfaceMode={surfaceMode}
@@ -205,9 +237,9 @@ export function WorkbenchMainFrame({
type="button" type="button"
aria-label={viewControlLabel} aria-label={viewControlLabel}
onClick={() => onClick={() =>
onSetArtifactView( (dock?.onSetView ?? onSetArtifactView)(
surfaceMode === "focus" surfaceMode === "focus"
? mapSplitAvailable ? splitAvailable
? "map_split" ? "map_split"
: "map_only" : "map_only"
: "focus" : "focus"
@@ -243,12 +275,12 @@ export function WorkbenchMainFrame({
</div> </div>
<motion.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} initial={false}
animate={{ animate={{
bottom: timelineBottom, bottom: timelineBottom,
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth, maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
right: 12 right: dockActive && mapSplit ? `calc(${100 - mapPercentage}% + 12px)` : 12
}} }}
transition={timelineTransition} transition={timelineTransition}
> >
@@ -2,6 +2,8 @@ import type { FeatureCollection, Geometry } from "geojson";
export const ARTIFACT_PANE_MIN_WIDTH = 600; export const ARTIFACT_PANE_MIN_WIDTH = 600;
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1; 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 ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived"; export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
+60
View File
@@ -1,6 +1,8 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest"; import { afterEach, expect, it } from "vitest";
import { FloatingWindow } from "./floating-window"; import { FloatingWindow } from "./floating-window";
import { floatingWindowEntries, floatingWindowOrder } from "./floating-window-layer";
import { useCallback, useRef, useState } from "react";
afterEach(cleanup); afterEach(cleanup);
@@ -35,3 +37,61 @@ it("retains manual opening, Escape dismissal and focus return", () => {
fireEvent.click(trigger); fireEvent.click(trigger);
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible(); 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("保留第一个来源");
});
+63 -22
View File
@@ -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 { createPortal } from "react-dom";
import { Maximize2, Minimize2, X } from "lucide-react"; import { Maximize2, Minimize2, X } from "lucide-react";
import { Button } from "./button"; import { Button } from "./button";
@@ -8,6 +8,14 @@ import { useFloatingWindowDrag } from "./use-floating-window-drag";
import { useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize"; import { useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
type Bounds = { x: number; y: number; width: number; height: number }; 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> = { const resizeHandles: Record<ResizeEdge, React.CSSProperties> = {
top: { top: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" }, top: { top: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
right: { right: 0, top: 18, bottom: 18, width: 8, cursor: "ew-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)) }; 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; title: string;
navigationTitle?: string; navigationTitle?: string;
triggerLabel?: string; triggerLabel?: string;
@@ -37,6 +45,8 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
fillContent?: boolean; fillContent?: boolean;
/** A new display request opens the window without taking keyboard focus. */ /** A new display request opens the window without taking keyboard focus. */
openRequestKey?: string; openRequestKey?: string;
/** An optional layout host; individual windows remain floating by default. */
dock?: FloatingWindowDock | null;
children: (size: { width: number; height: number }) => ReactNode; children: (size: { width: number; height: number }) => ReactNode;
}) { }) {
const id = useId(); const id = useId();
@@ -55,11 +65,23 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
const trigger = useRef<HTMLButtonElement>(null); const trigger = useRef<HTMLButtonElement>(null);
const heading = useRef<HTMLDivElement>(null); const heading = useRef<HTMLDivElement>(null);
const focusOnOpen = useRef(false); 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 = () => { const close = () => {
setManuallyOpen(false); setManuallyOpen(false);
setRequest({ key: openRequestKey, dismissed: true }); setRequest({ key: openRequestKey, dismissed: true });
removeFloatingWindow(id); removeFloatingWindow(id);
dock?.deactivate(id);
if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement)) if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement))
trigger.current?.focus({ preventScroll: true }); trigger.current?.focus({ preventScroll: true });
}; };
@@ -67,14 +89,33 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
const closeRef = useRef(close); const closeRef = useRef(close);
closeRef.current = close; closeRef.current = close;
useLayoutEffect(() => { useLayoutEffect(() => {
if (open) registerFloatingWindow({ id, title: navigationTitle ?? title, if (open && !docked) registerFloatingWindow({ id, title: navigationTitle ?? title,
focus: () => { raiseFloatingWindow(id); heading.current?.focus({ preventScroll: true }); }, focus: focusWindow,
close: () => closeRef.current() }); 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]); useEffect(() => () => removeFloatingWindow(id), [id]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return; if (!open || docked) return;
const others = floatingWindowOrder().filter((windowId) => windowId !== id); const others = floatingWindowOrder().filter((windowId) => windowId !== id);
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading")) const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
.reduce((height, element) => Math.max(height, element.offsetHeight), 48); .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, 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) })); width: initialSizeRef.current.width, height: Math.min(initialSizeRef.current.height, window.innerHeight - y - 8) }));
raiseFloatingWindow(id); raiseFloatingWindow(id);
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
focusOnOpen.current = false;
const resize = () => { const resize = () => {
setViewport({ width: window.innerWidth, height: window.innerHeight }); setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds((current) => { setBounds((current) => {
@@ -104,16 +143,16 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
window.removeEventListener("resize", resize); window.removeEventListener("resize", resize);
removeFloatingWindow(id); removeFloatingWindow(id);
}; };
}, [id, open]); }, [id, open, docked]);
const current = maximized const current = maximized
? { x: 8, y: floatingWindowTopInset(), width: viewport.width - 16, height: viewport.height - floatingWindowTopInset() - 8 } ? { x: 8, y: floatingWindowTopInset(), width: viewport.width - 16, height: viewport.height - floatingWindowTopInset() - 8 }
: bounds; : bounds;
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current, const startDrag = useFloatingWindowDrag({ enabled: open && !maximized && !docked, position: current,
width: current.width, height: current.height, width: current.width, height: current.height,
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) }); 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 <> return <>
<Button ref={trigger} type="button" variant="ghost" size={triggerContent ? "sm" : "icon-sm"} className="shrink-0" <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); setMaximized(false);
focusOnOpen.current = true; focusOnOpen.current = true;
setManuallyOpen(true); setManuallyOpen(true);
} else heading.current?.focus({ preventScroll: true }); } else focusWindow();
raise(); raise();
}}><Maximize2 size={15} aria-hidden />{triggerContent}</Button> }}><Maximize2 size={15} aria-hidden />{triggerContent}</Button>
{open ? createPortal( {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 }} 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} 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} onPointerDownCapture={raise} onFocusCapture={raise}
onKeyDown={(event: React.KeyboardEvent) => { onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); } if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
}}> }}>
<div ref={heading} tabIndex={0} <div ref={heading} tabIndex={0}
onPointerDown={startDrag} 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" 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="窗口标题栏,可用方向键移动,Shift 加方向键调整大小" aria-label={docked ? `${title}标题栏` : "窗口标题栏,可用方向键移动,Shift 加方向键调整大小"}
onKeyDown={(event) => { 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(); event.preventDefault();
const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0; const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0;
const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 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> <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"> <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)}> title={maximized ? "还原窗口" : "最大化窗口"} onClick={() => setMaximized(!maximized)}>
{maximized ? <Minimize2 size={15} aria-hidden /> : <Maximize2 size={15} aria-hidden />} {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}> <Button type="button" variant="ghost" size="icon-sm" aria-label="关闭窗口" title="关闭窗口" onClick={close}>
<X size={16} aria-hidden /> <X size={16} aria-hidden />
</Button> </Button>
@@ -165,13 +206,13 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
{children({ width: current.width - 26, height: current.height - 76 })} {children({ width: current.width - 26, height: current.height - 76 })}
</div> </div>
</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} <div key={edge} aria-hidden data-window-resize-handle={edge}
className={`absolute z-10 touch-none ${edge === "bottomRight" ? "floating-window-resize" : ""}`} className={`absolute z-10 touch-none ${edge === "bottomRight" ? "floating-window-resize" : ""}`}
style={style} onPointerDown={(event) => startResize(event, edge)}> 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} {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> </div>
)) : null} )) : null}
</div>, floatingWindowHost()) : null} </div>, docked ? dockHost : floatingWindowHost()) : null}
</>; </>;
} }
@@ -168,7 +168,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
expect(second).not.toBeNull(); expect(second).not.toBeNull();
return [first!, second!].map((box) => ({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) })); return [first!, second!].map((box) => ({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }));
}; };
await expect(panel).toHaveCSS("width", "880px"); await expect(panel).toHaveAttribute("data-presentation", "workspace");
const scheduledColumns = await captureColumns(); const scheduledColumns = await captureColumns();
await testInfo.attach("scheduled-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("scheduled-tab-alignment.png") }), contentType: "image/png" }); await testInfo.attach("scheduled-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("scheduled-tab-alignment.png") }), contentType: "image/png" });
await panel.getByRole("button", { name: /预约任务/ }).click(); await panel.getByRole("button", { name: /预约任务/ }).click();
@@ -378,7 +378,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0); await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click(); await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
await expect(page.getByRole("button", { name: /展开 Agent 助手面板/ })).toBeVisible(); await expect(page.getByRole("button", { name: /展开 Agent 助手面板/ })).toBeVisible();
await expect(panel).toHaveCSS("width", "880px"); await expect(panel).toHaveAttribute("data-presentation", "workspace");
await panel.getByRole("heading", { name: "当前结果", exact: true }).scrollIntoViewIfNeeded(); await panel.getByRole("heading", { name: "当前结果", exact: true }).scrollIntoViewIfNeeded();
await testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({ await testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({
path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" }); path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" });
@@ -401,6 +401,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" }); const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" });
if (await mobileExpand.isVisible()) await mobileExpand.click(); if (await mobileExpand.isVisible()) await mobileExpand.click();
await mobilePanel.getByRole("button", { name: /调查任务/ }).click(); await mobilePanel.getByRole("button", { name: /调查任务/ }).click();
await mobilePanel.getByText("水质投诉动态调查", { exact: true }).first().click();
await mobilePanel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded(); await mobilePanel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
await expect(mobilePanel.getByLabel("调查脉络", { exact: true }).locator("svg[id]")).toHaveCount(1); await expect(mobilePanel.getByLabel("调查脉络", { exact: true }).locator("svg[id]")).toHaveCount(1);
const mobileDiagram = mobilePanel.getByLabel("调查脉络", { exact: true }); const mobileDiagram = mobilePanel.getByLabel("调查脉络", { exact: true });
@@ -0,0 +1,118 @@
import { expect, test } from "@playwright/test";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation, showInvestigationMap, showInvestigationTasks } from "./support/mock-investigation-presentation";
import { responsesFixture } from "./support/responses-fixture";
test.use({ reducedMotion: "reduce" });
const chart = (id: string) => ({ action: "open_chart", chart_id: id, evidence_refs: ["synthetic:pressure"],
chart: { title: `停靠监测 ${id}`, chart_type: "line", x_data: ["07:45", "08:00"], series: [{ name: "压力(m)", data: [32, 28] }] } });
test("chart manager occupies the right workspace and preserves charts when switching to tasks", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1920, height: 1000 });
const task = investigationFixture(1, "右侧图表工作区验收");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const presentation = page.getByLabel("调查实时展示", { exact: true });
await expect(presentation).toContainText("实时跟随");
await mock.emit(task, chart("P1"));
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager).toBeVisible();
await expect(manager).toHaveAttribute("data-window-presentation", "docked");
await expect(page.getByRole("button", { name: /窗口管理,当前/ })).toHaveCount(0);
await expect(manager.getByRole("button", { name: /最大化窗口|还原窗口/ })).toHaveCount(0);
await expect(manager.locator("[data-window-resize-handle]")).toHaveCount(0);
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeHidden();
const box = (await manager.boundingBox())!;
const map = (await page.locator("#workspace-map").boundingBox())!;
expect(box.y).toBe(56);
expect(box.x).toBeGreaterThanOrEqual(map.x + map.width);
expect(Math.round(box.x + box.width)).toBe(1920);
await expect(manager.getByRole("img", { name: "停靠监测 P1", exact: true })).toBeVisible();
const separator = page.getByRole("separator", { name: "调整地图与报告宽度" });
await separator.focus();
await page.keyboard.press("End");
await expect.poll(async () => Math.round((await manager.boundingBox())!.width)).toBe(600);
await page.screenshot({ path: testInfo.outputPath("chart-workspace-minimum.png") });
const instance = await manager.locator(".echarts-for-react").getAttribute("_echarts_instance_");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await expect(manager).toBeHidden();
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
await showInvestigationMap(page);
await presentation.getByRole("button", { name: "打开图表管理", exact: true }).press("Enter");
await expect(manager).toBeVisible();
await expect(manager.getByLabel("图表管理标题栏", { exact: true })).toBeFocused();
await expect(manager.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
for (const width of [1200, 1024, 390, 320]) {
await page.setViewportSize({ width, height: 800 });
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
const bounds = (await manager.boundingBox())!;
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width);
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: testInfo.outputPath(`chart-workspace-${width}.png`) });
}
await page.setViewportSize({ width: 1920, height: 1000 });
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await expect(manager).toHaveCount(0);
await expect(page.locator("#workspace-map")).toBeVisible();
await showInvestigationTasks(page);
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
test("multiple chart sources share the visible dock without resetting their charts", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1000 });
const task = investigationFixture(1, "多来源停靠验收");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await mock.emit(task, chart("P1"));
const first = page.getByRole("dialog", { name: "图表管理", exact: true }).filter({ hasText: task.title });
await expect(first).toBeVisible();
await expect(first.locator(".echarts-for-react svg")).toBeVisible();
const instance = await first.locator(".echarts-for-react").getAttribute("_echarts_instance_");
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill(JSON.stringify({ record_type: "presentation", command: chart("preview") }));
await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成");
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "打开图表管理", exact: true }).press("Enter");
const second = page.getByRole("dialog", { name: "图表管理", exact: true }).filter({ hasText: "本地预览展示" });
await expect(second).toBeVisible();
expect((await second.boundingBox())!.y).toBe(56);
await expect(first).toBeHidden();
await page.getByLabel("调查实时展示", { exact: true }).getByRole("button", { name: "打开图表管理", exact: true }).press("Enter");
await expect(first).toBeVisible();
expect((await first.boundingBox())!.y).toBe(56);
await expect(second).toBeHidden();
await expect(first.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
await expect(page.getByRole("button", { name: /窗口管理,当前/ })).toHaveCount(0);
});
test("Agent map location reveals the map while the chart workspace is focused", async ({ page }) => {
await page.setViewportSize({ width: 1200, height: 900 });
const task = investigationFixture(1, "图表专注地图定位验收");
const mock = await mockInvestigationPresentation(page, [task]);
const block = { version: "answer-ui@1", block_id: "dock-location", fallback_text: "合成地图定位", spec: {
root: "location", elements: { location: { type: "MapLocation", props: { label: "定位合成监测点", layer: "junctions", feature_ids: ["P1"], source: "浏览器验收" }, children: [] } }
} };
await page.route("**/api/v1/responses", (route) => route.request().method() === "OPTIONS" ? route.fallback() : route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" }, body: responsesFixture([
{ type: "start", messageId: "dock-location-answer" }, { type: "data-answer_ui", id: block.block_id, data: block },
{ type: "data-final_answer", data: { content: "合成地图定位", ui_block_ids: [block.block_id] } }, { type: "finish", finishReason: "stop" }
])
}));
await selectInvestigation(page, task.title);
await mock.emit(task, chart("P1"));
await expect(page.getByRole("dialog", { name: "图表管理", exact: true })).toBeVisible();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const agent = page.getByRole("complementary", { name: "Agent 命令面板" });
await agent.locator(".agent-panel-prompt-textarea").fill("定位合成监测点");
await agent.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(page.locator("#workspace-map")).toBeHidden();
await agent.getByRole("button", { name: "定位合成监测点", exact: true }).click();
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_only");
await expect(page.getByRole("button", { name: "返回图表管理", exact: true })).toBeVisible();
});
@@ -77,6 +77,7 @@ async function resolvedColor(element: Locator, token: string, property: string)
} }
async function setup(page: Page, count: number) { async function setup(page: Page, count: number) {
await page.setViewportSize({ width: 1920, height: 1000 });
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({ await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2" path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2"
})); }));
@@ -301,10 +302,8 @@ test("scrolling layouts adapt charts and retain empty places without turning int
} }
expect(dimensions.one.width).toBeGreaterThan(dimensions.two.width); expect(dimensions.one.width).toBeGreaterThan(dimensions.two.width);
expect(dimensions.one.height).toBeGreaterThan(dimensions.four.height); expect(dimensions.one.height).toBeGreaterThan(dimensions.four.height);
const heading = manager.getByLabel("窗口标题栏,可用方向键移动,Shift 加方向键调整大小", { exact: true }); await page.setViewportSize({ width: 1920, height: 480 });
await heading.focus(); await expect.poll(async () => (await manager.boundingBox())!.height).toBe(424);
for (let index = 0; index < 20; index++) await page.keyboard.press("Shift+ArrowUp");
expect((await manager.boundingBox())!.height).toBeLessThanOrEqual(441);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click(); await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P3" }).click(); await page.getByRole("option").filter({ hasText: "合成监测 P3" }).click();
await expect(card(manager, "P3")).toBeFocused(); await expect(card(manager, "P3")).toBeFocused();
@@ -369,7 +368,7 @@ test("default keyboard dragging swaps fixed slots and narrow scrolling grids ret
await expect.poll(() => manager.locator("[data-chart-manager-scroll]").evaluate((node) => node.scrollTop)).toBeLessThanOrEqual(2); await expect.poll(() => manager.locator("[data-chart-manager-scroll]").evaluate((node) => node.scrollTop)).toBeLessThanOrEqual(2);
} }
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await manager.getByRole("button", { name: "最大化窗口", exact: true }).click(); await page.getByRole("button", { name: "专注图表", exact: true }).click();
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "four"); await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "four");
await expect(slots(manager)).toHaveCount(6); await expect(slots(manager)).toHaveCount(6);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click(); await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { basename, resolve } from "node:path"; import { basename, resolve } from "node:path";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation"; import { investigationFixture, mockInvestigationPresentation, selectInvestigation, showInvestigationMap, showInvestigationTasks } from "./support/mock-investigation-presentation";
const chart = (id: string, value = 28) => ({ action: "open_chart", chart_id: id, evidence_refs: ["synthetic:pressure"], const chart = (id: string, value = 28) => ({ action: "open_chart", chart_id: id, evidence_refs: ["synthetic:pressure"],
chart: { title: `合成监测 ${id}`, chart_type: "line", x_data: ["07:45", "08:00"], series: [{ name: "压力(m)", data: [32, value] }] } }); chart: { title: `合成监测 ${id}`, chart_type: "line", x_data: ["07:45", "08:00"], series: [{ name: "压力(m)", data: [32, value] }] } });
@@ -17,10 +17,12 @@ test("one manager receives live charts and keeps independent enlarged children",
const task = investigationFixture(1, "多图统一窗口验收"); const task = investigationFixture(1, "多图统一窗口验收");
const mock = await mockInvestigationPresentation(page, [task]); const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title); await selectInvestigation(page, task.title);
await expect(page.getByLabel("调查实时展示", { exact: true })).toContainText("实时跟随");
await mock.emit(task, chart("P1")); await mock.emit(task, chart("P1"));
const manager = page.getByRole("dialog", { name: "图表管理", exact: true }); const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager).toBeVisible(); await expect(manager).toBeVisible();
await mock.emit(task, chart("P2")); await mock.emit(task, chart("P2"));
await manager.locator("[data-managed-chart]").filter({ hasText: "合成监测 P2" }).scrollIntoViewIfNeeded();
await expect(manager.getByRole("img", { name: "合成监测 P2", exact: true })).toBeVisible(); await expect(manager.getByRole("img", { name: "合成监测 P2", exact: true })).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(1); await expect(page.locator("[data-floating-window]")).toHaveCount(1);
await expect(manager).toContainText("2 张图表"); await expect(manager).toContainText("2 张图表");
@@ -45,7 +47,7 @@ test("one manager receives live charts and keeps independent enlarged children",
expect((await first.boundingBox())!.width).toBe(1424); expect((await first.boundingBox())!.width).toBe(1424);
await first.getByRole("button", { name: "关闭窗口", exact: true }).click(); await first.getByRole("button", { name: "关闭窗口", exact: true }).click();
await manager.locator(".floating-window-heading").focus(); await manager.locator(".floating-window-heading").focus();
await manager.getByRole("button", { name: "最大化窗口", exact: true }).click(); await page.getByRole("button", { name: "专注图表", exact: true }).click();
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
await testInfo.attach("chart-manager-desktop", { body: await manager.screenshot({ path: testInfo.outputPath("chart-manager-desktop.png") }), contentType: "image/png" }); await testInfo.attach("chart-manager-desktop", { body: await manager.screenshot({ path: testInfo.outputPath("chart-manager-desktop.png") }), contentType: "image/png" });
for (const width of [375, 320]) { for (const width of [375, 320]) {
@@ -61,6 +63,7 @@ test("one manager receives live charts and keeps independent enlarged children",
const panel = page.getByLabel("调查实时展示", { exact: true }); const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.locator('[data-investigation-chart="P3"]')).toHaveCount(1); await expect(panel.locator('[data-investigation-chart="P3"]')).toHaveCount(1);
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
if (page.viewportSize()!.width >= 1024) await showInvestigationMap(page);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click(); await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager).toContainText("3 张图表"); await expect(manager).toContainText("3 张图表");
expect(mock.mutations).toEqual([]); expect(mock.mutations).toEqual([]);
@@ -87,21 +90,27 @@ test("opening, switching, refreshing and reconnecting tasks restores charts sile
await expect(panel.locator('[data-investigation-chart="P1"]')).toHaveCount(1); await expect(panel.locator('[data-investigation-chart="P1"]')).toHaveCount(1);
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
// Create a live request before returning to the same task later. // Create a live request before returning to the same task later.
await expect(panel).toContainText("实时跟随");
await mock.emit(running, chart("P1", 23)); await mock.emit(running, chart("P1", 23));
await expect(manager).toBeVisible(); await expect(manager).toBeVisible();
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click(); await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
const tasks = page.getByRole("region", { name: "工况任务", exact: true }); const tasks = page.getByRole("region", { name: "工况任务", exact: true });
await showInvestigationTasks(page);
await tasks.getByText(completed.title, { exact: true }).first().click(); await tasks.getByText(completed.title, { exact: true }).first().click();
await expect(panel.locator('[data-investigation-chart="P2"]')).toHaveCount(1); await expect(panel.locator('[data-investigation-chart="P2"]')).toHaveCount(1);
await showInvestigationMap(page);
await expect(panel.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible(); await expect(panel.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await showInvestigationMap(page);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click(); await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager).toContainText("2 张图表"); await expect(manager).toContainText("2 张图表");
await expect(manager.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible(); await expect(manager.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible();
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click(); await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await showInvestigationTasks(page);
await tasks.getByText(running.title, { exact: true }).first().click(); await tasks.getByText(running.title, { exact: true }).first().click();
await expect(panel.locator('[data-investigation-chart="P1"]')).toHaveCount(1); await expect(panel.locator('[data-investigation-chart="P1"]')).toHaveCount(1);
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await showInvestigationMap(page);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click(); await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager).toBeVisible(); await expect(manager).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
+40 -10
View File
@@ -1,6 +1,25 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation"; import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
async function selectSecondObject(page: Page, surface: Locator) {
const select = surface.getByRole("combobox", { name: "选择图表对象", exact: true });
let label: string;
if (await select.isVisible()) {
await select.click();
const option = page.getByRole("option").nth(1);
label = (await option.innerText()).trim();
await option.click();
} else {
const button = surface.getByRole("button", { name: /^切换对象 / }).nth(1);
label = (await button.getAttribute("aria-label"))!.replace("切换对象 ", "");
await button.click();
}
return async () => {
if (await select.isVisible()) await expect(select).toContainText(label);
else await expect(surface.getByRole("button", { name: `切换对象 ${label}`, exact: true })).toHaveAttribute("aria-pressed", "true");
};
}
test.use({ reducedMotion: "reduce", viewport: { width: 1600, height: 1000 } }); test.use({ reducedMotion: "reduce", viewport: { width: 1600, height: 1000 } });
for (const target of ["manager", "child"] as const) { for (const target of ["manager", "child"] as const) {
test(`resizing ${target} reflows charts at their actual dimensions without stretching text`, async ({ page }, testInfo) => { test(`resizing ${target} reflows charts at their actual dimensions without stretching text`, async ({ page }, testInfo) => {
@@ -13,6 +32,7 @@ for (const target of ["manager", "child"] as const) {
await page.getByRole("option", { name: "复杂图表:多测点与长时序", exact: true }).click(); await page.getByRole("option", { name: "复杂图表:多测点与长时序", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成", { timeout: 20_000 }); await expect(tools.getByRole("status")).toContainText("本地预览完成", { timeout: 20_000 });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByLabel("本地预览展示", { exact: true }); const panel = page.getByLabel("本地预览展示", { exact: true });
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click(); await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
const manager = page.getByRole("dialog", { name: "图表管理", exact: true }); const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
@@ -27,7 +47,8 @@ for (const target of ["manager", "child"] as const) {
size: getComputedStyle(node).fontSize, height: node.getBoundingClientRect().height size: getComputedStyle(node).fontSize, height: node.getBoundingClientRect().height
})); }));
const before = (await popup.boundingBox())!; const before = (await popup.boundingBox())!;
const handle = (await popup.locator(".floating-window-resize").boundingBox())!; const resizeControl = target === "manager" ? page.getByRole("separator", { name: "调整地图与报告宽度" }) : popup.locator(".floating-window-resize");
const handle = (await resizeControl.boundingBox())!;
const session = await page.context().newCDPSession(page); const session = await page.context().newCDPSession(page);
await session.send("Emulation.setCPUThrottlingRate", { rate: 4 }); await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await session.send("Performance.enable"); await session.send("Performance.enable");
@@ -41,9 +62,12 @@ for (const target of ["manager", "child"] as const) {
}); });
const first = await session.send("Performance.getMetrics"); const first = await session.send("Performance.getMetrics");
const started = Date.now(); const started = Date.now();
await page.mouse.move(handle.x + handle.width - 3, handle.y + handle.height - 3); const pointer = target === "manager"
? { x: handle.x + handle.width / 2, y: handle.y + handle.height / 2, dx: -128, dy: 0 }
: { x: handle.x + handle.width - 3, y: handle.y + handle.height - 3, dx: 128, dy: 64 };
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(handle.x + handle.width - 3 + 128, handle.y + handle.height - 3 + 64, { steps: 32 }); await page.mouse.move(pointer.x + pointer.dx, pointer.y + pointer.dy, { steps: 32 });
// While the pointer is still held, layout and the chart drawing must adapt together. // While the pointer is still held, layout and the chart drawing must adapt together.
await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1); await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1);
await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientHeight - Number(node.querySelector("svg")?.getAttribute("height"))))).toBeLessThan(1); await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientHeight - Number(node.querySelector("svg")?.getAttribute("height"))))).toBeLessThan(1);
@@ -65,7 +89,7 @@ for (const target of ["manager", "child"] as const) {
console.info("Chart resize profile", JSON.stringify(summary)); console.info("Chart resize profile", JSON.stringify(summary));
await testInfo.attach("resize-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" }); await testInfo.attach("resize-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
expect(Math.round(preview.width - before.width)).toBe(128); expect(Math.round(preview.width - before.width)).toBe(128);
expect(Math.round(preview.height - before.height)).toBe(64); expect(Math.round(preview.height - before.height)).toBe(target === "manager" ? 0 : 64);
expect(during).toBeGreaterThan(0); expect(during).toBeGreaterThan(0);
// Bound the expensive work on the same 4x CPU fixture used to reproduce the lag. // Bound the expensive work on the same 4x CPU fixture used to reproduce the lag.
expect(summary.elapsedMs).toBeLessThan(12_000); expect(summary.elapsedMs).toBeLessThan(12_000);
@@ -89,13 +113,13 @@ for (const target of ["manager", "child"] as const) {
await testInfo.attach("resize-layout", { body: JSON.stringify(layout, null, 2), contentType: "application/json" }); await testInfo.attach("resize-layout", { body: JSON.stringify(layout, null, 2), contentType: "application/json" });
await session.send("Emulation.setCPUThrottlingRate", { rate: 1 }); await session.send("Emulation.setCPUThrottlingRate", { rate: 1 });
const chartSurface = target === "manager" ? manager.getByRole("group", { name: "合成全天压力对比", exact: true }) : popup; const chartSurface = target === "manager" ? manager.getByRole("group", { name: "合成全天压力对比", exact: true }) : popup;
const selectedObject = chartSurface.getByRole("button", { name: /^切换对象 / }).nth(1); const expectSelectedObject = await selectSecondObject(page, chartSurface);
await selectedObject.click();
const hiddenSeries = chartSurface.getByLabel("显示的数据序列").getByRole("button").last(); const hiddenSeries = chartSurface.getByLabel("显示的数据序列").getByRole("button").last();
await hiddenSeries.click(); await hiddenSeries.click();
await expect(selectedObject).toHaveAttribute("aria-pressed", "true"); await expectSelectedObject();
await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false"); await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false");
const selectedInstance = await chartSurface.locator(".echarts-for-react").first().getAttribute("_echarts_instance_"); const selectedInstance = await chartSurface.locator(".echarts-for-react").first().getAttribute("_echarts_instance_");
if (target === "child") {
const beforeCancel = (await popup.boundingBox())!; const beforeCancel = (await popup.boundingBox())!;
const cancelHandle = popup.locator(".floating-window-resize"); const cancelHandle = popup.locator(".floating-window-resize");
const cancelBox = (await cancelHandle.boundingBox())!; const cancelBox = (await cancelHandle.boundingBox())!;
@@ -112,7 +136,7 @@ for (const target of ["manager", "child"] as const) {
await page.mouse.up(); await page.mouse.up();
await expect.poll(() => popup.boundingBox()).toEqual(beforeCancel); await expect.poll(() => popup.boundingBox()).toEqual(beforeCancel);
await expect(popup.locator("[data-floating-window-document]")).toHaveCSS("transform", "none"); await expect(popup.locator("[data-floating-window-document]")).toHaveCSS("transform", "none");
await expect(selectedObject).toHaveAttribute("aria-pressed", "true"); await expectSelectedObject();
await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false"); await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false");
const boundaryBox = (await cancelHandle.boundingBox())!; const boundaryBox = (await cancelHandle.boundingBox())!;
await page.mouse.move(boundaryBox.x + boundaryBox.width - 3, boundaryBox.y + boundaryBox.height - 3); await page.mouse.move(boundaryBox.x + boundaryBox.width - 3, boundaryBox.y + boundaryBox.height - 3);
@@ -122,7 +146,13 @@ for (const target of ["manager", "child"] as const) {
const final = (await popup.boundingBox())!; const final = (await popup.boundingBox())!;
expect(Math.round(final.x + final.width)).toBe(1592); expect(Math.round(final.x + final.width)).toBe(1592);
expect(Math.round(final.y + final.height)).toBe(992); expect(Math.round(final.y + final.height)).toBe(992);
await expect(selectedObject).toHaveAttribute("aria-pressed", "true"); } else {
await expect(popup.locator("[data-window-resize-handle]")).toHaveCount(0);
await resizeControl.focus();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowLeft");
}
await expectSelectedObject();
await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false"); await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false");
await expect(chartSurface.locator(".echarts-for-react").first()).toHaveAttribute("_echarts_instance_", selectedInstance!); await expect(chartSurface.locator(".echarts-for-react").first()).toHaveAttribute("_echarts_instance_", selectedInstance!);
await expect.poll(() => chartSurface.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1); await expect.poll(() => chartSurface.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1);
+6 -2
View File
@@ -192,13 +192,17 @@ for (const width of [1440, 375]) {
await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 }); await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 });
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成"); await expect(tools.getByRole("status")).toContainText("本地预览完成");
await page.getByRole("button", { name: "切换地图 Dev Panel", exact: true }).click();
await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "打开图表管理", exact: true }).click(); await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(page.getByRole("dialog", { name: "图表管理", exact: true })).toBeVisible(); const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager).toBeVisible();
await expect(header.getByRole("button", { name: /窗口管理,当前/ })).toHaveCount(0);
await manager.getByRole("button", { name: "放大查看 交互验证压力趋势", exact: true }).click();
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
const navigator = header.getByRole("button", { name: /窗口管理,当前/ }); const navigator = header.getByRole("button", { name: /窗口管理,当前/ });
await navigator.click(); await navigator.click();
const windowMenu = page.getByRole("menu", { name: "已打开的窗口", exact: true }); const windowMenu = page.getByRole("menu", { name: "已打开的窗口", exact: true });
const currentWindow = windowMenu.getByRole("menuitem", { name: /定位窗口 .*图表管理/ }); const currentWindow = windowMenu.getByRole("menuitem", { name: "定位窗口 交互验证压力趋势", exact: true });
await expect(currentWindow).toHaveAttribute("aria-current", "true"); await expect(currentWindow).toHaveAttribute("aria-current", "true");
await expectPreservedOnHover(page, currentWindow, true); await expectPreservedOnHover(page, currentWindow, true);
await expect(currentWindow).toHaveCSS("background-color", await themeColor(currentWindow, "--action-selection-soft", "background-color")); await expect(currentWindow).toHaveCSS("background-color", await themeColor(currentWindow, "--action-selection-soft", "background-color"));
@@ -1,8 +1,12 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order"; import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test.describe("mobile workbench sheet", () => { test.describe("mobile workbench sheet", () => {
test.use({ viewport: { width: 390, height: 844 } }); test.use({ viewport: { width: 390, height: 844 } });
test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(page);
});
test("keeps the bottom launcher controls at one height", async ({ page }) => { test("keeps the bottom launcher controls at one height", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -72,7 +72,7 @@ test("keeps the open scheduled report when the rolling history refreshes", async
await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveCount(0); await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveCount(0);
}); });
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => { test("collapsed conditions use acrylic and expand into the analysis workspace", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await openConditionFeed(page); await openConditionFeed(page);
@@ -84,15 +84,14 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
const timeline = panel.getByTestId("scheduled-condition-timeline"); const timeline = panel.getByTestId("scheduled-condition-timeline");
const detailViewport = panel.locator(".scheduled-feed-detail-viewport"); const detailViewport = panel.locator(".scheduled-feed-detail-viewport");
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll"); const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
const collapsedPanelBox = await panel.boundingBox();
const collapsedTimelineBox = await timeline.boundingBox(); const collapsedTimelineBox = await timeline.boundingBox();
expect(collapsedPanelBox).not.toBeNull();
expect(collapsedTimelineBox).not.toBeNull(); expect(collapsedTimelineBox).not.toBeNull();
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
await expectAcrylicComposition(panel); await expect(panel).toHaveAttribute("data-presentation", "workspace");
await expect(panel).toHaveCSS("width", "880px"); await expect(panel).toHaveCSS("backdrop-filter", "none");
await timeline.locator("button[data-condition-id]").first().click();
const expandedPanelBox = await panel.boundingBox(); const expandedPanelBox = await panel.boundingBox();
const expandedTimelineBox = await timeline.boundingBox(); const expandedTimelineBox = await timeline.boundingBox();
const expandedDetailViewportBox = await detailViewport.boundingBox(); const expandedDetailViewportBox = await detailViewport.boundingBox();
@@ -101,15 +100,9 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
expect(expandedTimelineBox).not.toBeNull(); expect(expandedTimelineBox).not.toBeNull();
expect(expandedDetailViewportBox).not.toBeNull(); expect(expandedDetailViewportBox).not.toBeNull();
expect(expandedDetailScrollBox).not.toBeNull(); expect(expandedDetailScrollBox).not.toBeNull();
expect(Math.round(expandedPanelBox!.x + expandedPanelBox!.width)).toBe(
Math.round(collapsedPanelBox!.x + collapsedPanelBox!.width)
);
expect(Math.round(expandedTimelineBox!.width)).toBe( expect(Math.round(expandedTimelineBox!.width)).toBe(
Math.round(collapsedTimelineBox!.width) Math.round(collapsedTimelineBox!.width)
); );
expect(Math.round(expandedTimelineBox!.x + expandedTimelineBox!.width)).toBe(
Math.round(collapsedTimelineBox!.x + collapsedTimelineBox!.width)
);
expect(expandedTimelineBox!.y + expandedTimelineBox!.height).toBeLessThanOrEqual( expect(expandedTimelineBox!.y + expandedTimelineBox!.height).toBeLessThanOrEqual(
expandedPanelBox!.y + expandedPanelBox!.height - 12 expandedPanelBox!.y + expandedPanelBox!.height - 12
); );
@@ -124,6 +117,7 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
); );
await expect(detailViewport).toHaveCSS("border-radius", "12px"); await expect(detailViewport).toHaveCSS("border-radius", "12px");
const timelineScroll = timeline.locator(".scheduled-feed-scroll.flex-1"); const timelineScroll = timeline.locator(".scheduled-feed-scroll.flex-1");
await page.mouse.move(0, 0);
expect(await readScrollbarPresentation(detailScroll)).toEqual( expect(await readScrollbarPresentation(detailScroll)).toEqual(
await readScrollbarPresentation(timelineScroll) await readScrollbarPresentation(timelineScroll)
); );
@@ -165,8 +159,8 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
await panel.getByRole("button", { name: "展开工况任务" }).focus(); await panel.getByRole("button", { name: "展开工况任务" }).focus();
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible(); await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(800); await expect(panel).toHaveAttribute("data-presentation", "workspace");
await expect.poll(async () => (await panel.boundingBox())?.width ?? Infinity).toBeLessThanOrEqual(880); await expect(panel).toHaveCSS("width", "1024px");
const expandedConditionBox = await panel.boundingBox(); const expandedConditionBox = await panel.boundingBox();
const collapsedAgentBox = await page const collapsedAgentBox = await page
@@ -175,8 +169,9 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
expect(expandedConditionBox).not.toBeNull(); expect(expandedConditionBox).not.toBeNull();
expect(collapsedAgentBox).not.toBeNull(); expect(collapsedAgentBox).not.toBeNull();
expect( expect(
expandedConditionBox!.x - (collapsedAgentBox!.x + collapsedAgentBox!.width) (await panel.getByRole("heading", { name: "工况任务", exact: true }).boundingBox())!.x
).toBeGreaterThanOrEqual(24); - (collapsedAgentBox!.x + collapsedAgentBox!.width)
).toBeGreaterThanOrEqual(0);
await panel.getByRole("button", { name: "收起工况任务" }).click(); await panel.getByRole("button", { name: "收起工况任务" }).click();
await expect(agentPanel).toBeVisible(); await expect(agentPanel).toBeVisible();
@@ -188,7 +183,8 @@ test("switching conditions keeps the detail viewport fully expanded", async ({ p
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel).toHaveCSS("width", "880px"); await expect(panel).toHaveAttribute("data-presentation", "workspace");
await panel.getByTestId("scheduled-condition-timeline").getByRole("button", { name: /SCADA 数据诊断/ }).first().click();
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll"); const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
const baselineHeight = await detailScroll.evaluate( const baselineHeight = await detailScroll.evaluate(
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { createAnalysisRuntimeSnapshot, MOCK_SPATIAL_ANALYSIS } from "./support/mock-analysis-runtime";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, createAnalysisRuntimeSnapshot(MOCK_SPATIAL_ANALYSIS));
});
test("docks the live task panel into analysis, enforces both minimum widths and restores the analysis", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1000 });
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开最新后端分析成果" }).click();
await expect(page.locator("#artifact-workspace")).toBeVisible();
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel.getByRole("button", { name: /停靠工况任务/ })).toHaveCount(0);
await expect(panel.locator("[data-condition-dock-toggle]")).toHaveCount(1);
await expect(panel.getByRole("button", { name: "展开工况任务" }).locator(".lucide-maximize2")).toBeVisible();
await expect(panel.locator('button[data-condition-id][aria-pressed="true"]')).toHaveCount(0);
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel).toHaveAttribute("data-presentation", "workspace");
await expect(panel.getByRole("button", { name: "收起工况任务" }).locator(".lucide-minimize2")).toBeVisible();
await expect(panel.getByText("请选择工况任务", { exact: true })).toBeVisible();
const rows = panel.getByTestId("scheduled-condition-timeline").locator("button[aria-pressed]");
await rows.nth(1).click();
const selectedId = await rows.nth(1).getAttribute("data-condition-id");
await panel.evaluate((element) => Object.assign(window, { originalDockPanel: element }));
await expect(page.locator("#condition-workspace").getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
await expect(page.locator("#artifact-workspace")).toBeHidden();
await expect(panel).toHaveAttribute("data-presentation", "workspace");
await expect(panel.locator(`[data-condition-id="${selectedId}"]`)).toHaveAttribute("aria-pressed", "true");
await expect(panel).toHaveCSS("backdrop-filter", "none");
const separator = page.getByRole("separator", { name: "调整地图与报告宽度" });
await expect(separator).toBeVisible();
await separator.focus();
await page.keyboard.press("Home");
await expect.poll(async () => Math.round((await page.locator("#workspace-map").boundingBox())!.width)).toBe(600);
await page.keyboard.press("End");
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(880);
const bounds = await panel.boundingBox();
expect(bounds!.y).toBe(56);
expect(Math.round(bounds!.height)).toBe(944);
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
expect(await detail.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
const timeline = page.getByRole("region", { name: "运行时间轴", exact: true });
const timelineBox = (await timeline.boundingBox())!;
expect(timelineBox.x + timelineBox.width).toBeLessThanOrEqual(bounds!.x);
await page.screenshot({ path: "test-results/condition-dock-minimum-width.png" });
await page.setViewportSize({ width: 1480, height: 900 });
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(panel).toHaveCSS("width", "1480px");
await page.setViewportSize({ width: 1481, height: 900 });
await expect(separator).toBeVisible();
await expect.poll(async () => Math.round((await page.locator("#workspace-map").boundingBox())!.width)).toBe(600);
await expect(panel).toHaveCSS("width", "880px");
await panel.getByRole("button", { name: "收起工况任务" }).click();
await expect(panel).toHaveAttribute("data-presentation", "desktop-floating");
await expect(page.locator("#artifact-workspace")).toBeVisible();
expect(await panel.evaluate((element) => element === (window as unknown as { originalDockPanel: Element }).originalDockPanel)).toBe(true);
await expect(panel.locator(`[data-condition-id="${selectedId}"]`)).toHaveAttribute("aria-pressed", "false");
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel.locator(`[data-condition-id="${selectedId}"]`)).toHaveAttribute("aria-pressed", "true");
expect(errors).toEqual([]);
});
test("opens and reopens without selecting a task, and selecting a row docks its detail", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1000 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const toggle = page.getByRole("button", { name: "工况任务", exact: true });
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await toggle.click();
await expect(panel.locator('button[data-condition-id][aria-pressed="true"]')).toHaveCount(0);
await panel.getByTestId("scheduled-condition-timeline").locator("button[data-condition-id]").nth(1).click();
await expect(panel).toHaveAttribute("data-presentation", "workspace");
await expect(panel.locator('button[data-condition-id][aria-pressed="true"]')).toHaveCount(1);
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toBeVisible();
await toggle.click();
await expect(panel).toBeHidden();
await toggle.click();
await expect(panel.locator('button[data-condition-id][aria-pressed="true"]')).toHaveCount(0);
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel.getByText("请选择工况任务", { exact: true })).toBeVisible();
await page.screenshot({ path: "test-results/condition-dock-no-selection.png" });
});
test("uses focus mode when space is limited and adapts through Agent and mobile layouts", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务", exact: true }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect.poll(async () => (await panel.boundingBox())!.width).toBeGreaterThanOrEqual(880);
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeHidden();
await expect(panel).toHaveCSS("width", "1024px");
await expect(page.getByRole("separator", { name: "调整地图与报告宽度" })).toBeHidden();
await page.getByRole("button", { name: "显示地图", exact: true }).click();
await expect(panel).toBeHidden();
await expect(page.locator("#workspace-map")).toBeVisible();
await page.getByRole("button", { name: "返回工况任务" }).click();
await expect(panel).toBeVisible();
const rail = (await page.getByRole("complementary", { name: "Agent 折叠栏" }).boundingBox())!;
const title = (await panel.getByRole("heading", { name: "工况任务", exact: true }).boundingBox())!;
expect(title.x).toBeGreaterThanOrEqual(rail.x + rail.width);
await page.screenshot({ path: "test-results/condition-dock-narrow-desktop.png" });
await page.setViewportSize({ width: 390, height: 844 });
await expect(panel).toBeHidden();
await page.getByRole("button", { name: "打开工况任务", exact: true }).click();
await expect(panel).toHaveAttribute("data-presentation", "mobile-sheet");
await expect(panel.locator('button[data-condition-id][aria-pressed="true"]')).toHaveCount(0);
await panel.getByTestId("scheduled-condition-timeline").locator("button[data-condition-id]").first().click();
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toBeVisible();
await expect(panel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
await expect(panel.getByRole("button", { name: /停靠工况任务/ })).toHaveCount(0);
await page.setViewportSize({ width: 1920, height: 1000 });
await expect(panel).toHaveAttribute("data-presentation", "workspace");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1920);
});
@@ -144,3 +144,15 @@ export async function selectInvestigation(page: Page, title: string) {
} }
await task.click(); await task.click();
} }
export async function showInvestigationMap(page: Page) {
const showMap = page.getByRole("button", { name: "显示地图", exact: true });
if (await showMap.isVisible()) await showMap.click();
}
export async function showInvestigationTasks(page: Page) {
if (await page.getByRole("region", { name: "工况任务", exact: true }).isVisible()) return;
const returnToTasks = page.getByRole("button", { name: "返回工况任务", exact: true });
if (await returnToTasks.isVisible()) await returnToTasks.click();
else await page.getByRole("button", { name: /^(?:打开)?工况任务$/ }).click();
}
+13 -9
View File
@@ -4,7 +4,7 @@ import { mockInvestigationPresentation } from "./support/mock-investigation-pres
test.use({ reducedMotion: "reduce" }); test.use({ reducedMotion: "reduce" });
for (const width of [1440, 375, 320]) { for (const width of [1440, 375, 320]) {
test(`header locates managed, chart and table windows without changing their state at ${width}px`, async ({ page }, testInfo) => { test(`header lists only floating chart and table windows and preserves the docked manager at ${width}px`, async ({ page }, testInfo) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({ await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
@@ -28,11 +28,12 @@ for (const width of [1440, 375, 320]) {
await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 }); await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 });
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成"); await expect(tools.getByRole("status")).toContainText("本地预览完成");
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByLabel("本地预览展示", { exact: true }); const panel = page.getByLabel("本地预览展示", { exact: true });
await panel.getByRole("button", { name: "放大查看表格 窗口定位核查表", exact: true }).click(); await panel.getByRole("button", { name: "放大查看表格 窗口定位核查表", exact: true }).click();
const table = page.getByRole("dialog", { name: "窗口定位核查表 · 表格", exact: true }); const table = page.getByRole("dialog", { name: "窗口定位核查表 · 表格", exact: true });
await expect(table).toBeVisible(); await expect(table).toBeVisible();
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click(); await panel.getByRole("button", { name: "打开图表管理", exact: true }).press("Enter");
const manager = page.getByRole("dialog", { name: "图表管理", exact: true }); const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await manager.getByRole("button", { name: "放大查看 窗口定位压力趋势", exact: true }).click(); await manager.getByRole("button", { name: "放大查看 窗口定位压力趋势", exact: true }).click();
const child = page.getByRole("dialog", { name: "窗口定位压力趋势", exact: true }); const child = page.getByRole("dialog", { name: "窗口定位压力趋势", exact: true });
@@ -42,7 +43,7 @@ for (const width of [1440, 375, 320]) {
await table.locator(".floating-window-heading").focus(); await table.locator(".floating-window-heading").focus();
await search.fill("P2"); await search.fill("P2");
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
await expect(navigator).toHaveAttribute("aria-label", "窗口管理,当前 3 个窗口"); await expect(navigator).toHaveAttribute("aria-label", "窗口管理,当前 2 个窗口");
if (width === 1440) { if (width === 1440) {
const alignment = await navigator.evaluate((node) => { const alignment = await navigator.evaluate((node) => {
const label = node.children[1].getBoundingClientRect(); const label = node.children[1].getBoundingClientRect();
@@ -55,19 +56,21 @@ for (const width of [1440, 375, 320]) {
const tableBounds = await table.boundingBox(); const tableBounds = await table.boundingBox();
await navigator.click(); await navigator.click();
const menu = page.getByRole("menu", { name: "已打开的窗口", exact: true }); const menu = page.getByRole("menu", { name: "已打开的窗口", exact: true });
await expect(menu.getByRole("menuitem")).toHaveCount(4); await expect(menu.getByRole("menuitem")).toHaveCount(3);
await expect(menu.getByRole("menuitem", { name: /图表管理/ })).toHaveCount(0);
await menu.getByRole("menuitem", { name: "定位窗口 窗口定位核查表 · 表格", exact: true }).click(); await menu.getByRole("menuitem", { name: "定位窗口 窗口定位核查表 · 表格", exact: true }).click();
await expect(table.locator(".floating-window-heading")).toBeFocused(); await expect(table.locator(".floating-window-heading")).toBeFocused();
await expect(navigator).toHaveAttribute("aria-expanded", "false"); await expect(navigator).toHaveAttribute("aria-expanded", "false");
expect(await table.boundingBox()).toEqual(tableBounds); expect(await table.boundingBox()).toEqual(tableBounds);
await expect(search).toHaveValue("P2"); await expect(search).toHaveValue("P2");
const managerBounds = await manager.boundingBox(); const childBounds = await child.boundingBox();
await navigator.focus(); await navigator.focus();
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await menu.getByRole("menuitem", { name: /定位窗口 .*图表管理/ }).focus(); await menu.getByRole("menuitem", { name: "定位窗口 窗口定位压力趋势", exact: true }).focus();
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(manager.locator(".floating-window-heading")).toBeFocused(); await expect(child.locator(".floating-window-heading")).toBeFocused();
expect(await manager.boundingBox()).toEqual(managerBounds); expect(await child.boundingBox()).toEqual(childBounds);
await expect(child.getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
await navigator.click(); await navigator.click();
await header.getByRole("button", { name: "打开用户菜单", exact: true }).click(); await header.getByRole("button", { name: "打开用户菜单", exact: true }).click();
await expect(menu).toHaveCount(0); await expect(menu).toHaveCount(0);
@@ -85,7 +88,8 @@ for (const width of [1440, 375, 320]) {
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`window-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath(`window-menu-${width}.png`) }), contentType: "image/png" }); await testInfo.attach(`window-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath(`window-menu-${width}.png`) }), contentType: "image/png" });
await menu.getByRole("menuitem", { name: "关闭全部窗口", exact: true }).click(); await menu.getByRole("menuitem", { name: "关闭全部窗口", exact: true }).click();
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator('[data-window-presentation="floating"]')).toHaveCount(0);
await expect(manager).toBeVisible();
await expect(navigator).toHaveCount(0); await expect(navigator).toHaveCount(0);
await expect(header.getByRole("button", { name: "打开用户菜单", exact: true })).toBeFocused(); await expect(header.getByRole("button", { name: "打开用户菜单", exact: true })).toBeFocused();
expect(mock.mutations).toEqual([]); expect(mock.mutations).toEqual([]);