feat(workbench): dock task and chart workspaces
This commit is contained in:
@@ -70,7 +70,7 @@ SCADA 的压力、水头(m)及流量、管流(m³/h)数值轴使用对
|
||||
|
||||
图表发布及 Dev 示例的对象聚合、同期校验和历史读取边界统一见 [调查展示 Dev 测试](investigation-dev-tools.md#对象复合图与验证规则)。结构化 Chart 保持单图语义,对象聚合通过 open_chart 的独立对象数据集实现。
|
||||
|
||||
[图表和窗口管理](investigation-dev-tools.md#图表和窗口管理)由浏览器宿主负责:总窗口收纳过程图组与报告 Chart,子图表保留独立弹窗放大。总窗口提供单图、左右、四宫格固定槽位,全部图表沿纵向连续排列,末行按列数补齐空槽;整条位置栏提供拖拽和悬停反馈,拖放到目标位置交换内容。窄窗口使用单图,快速定位会滚动到目标位置。同一图表更新保留位置,删图留空,新图先填空槽。顶部窗口列表可定位、置顶已打开的总窗口、子图表和表格,并统一关闭。窗口缩放期间,图表按窗口实际尺寸适配并保留图表实例与筛选状态;松开后确认最终尺寸,Escape 或指针取消恢复原尺寸。
|
||||
[图表和窗口管理](investigation-dev-tools.md#图表和窗口管理)由浏览器宿主负责:管理区占用右侧分析区域,收纳过程图组与报告 Chart,子图表保留独立弹窗放大。管理区提供单图、左右、四宫格固定槽位,全部图表沿纵向连续排列,末行按列数补齐空槽;整条位置栏提供拖拽和悬停反馈,拖放到目标位置交换内容。窄区域使用单图,快速定位会滚动到目标位置。同一图表更新保留位置,删图留空,新图先填空槽。顶部窗口列表仅定位、置顶和统一关闭独立子图表与表格浮窗,不包含图表管理区。拖动分隔条或缩放独立浮窗时,图表按实际尺寸适配并保留图表实例与筛选状态;独立浮窗缩放支持 Escape 或指针取消恢复原尺寸。
|
||||
|
||||
任务打开、切换、刷新、已完成任务载入和历史恢复只更新内容,不弹窗;运行中新收到的 open_chart 由总窗口统一处理,关闭后的实时更新不重新弹出。排列、布局和窗口操作不新增模型输出或 wire 协议字段,也不改变图表校验与证据规则。具体自动弹出与关闭边界以上述管理说明为准。
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
|
||||
|
||||
## 图表和窗口管理
|
||||
|
||||
调查、本地预览与对话展示面板提供“图表窗口”入口,在一个可拖动、缩放、最大化的总窗口中集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭总窗口收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。
|
||||
调查、本地预览与对话展示面板提供“图表窗口”入口,在右侧分析区域打开图表管理区,集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。管理区不提供浮窗拖动、边缘缩放或最大化按钮,通过工作区底部入口切换地图对照与专注图表。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭管理区收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。
|
||||
|
||||
图表管理区与地图分栏时,两侧最小宽度均为 600px;空间不足时显示专注图表。工况任务沿用展开按钮,展开后占用右侧分析区域,任务区最小宽度为 880px;普通打开或重新打开时默认不选择条目。切换工况任务与图表管理区保留已加载的图表、布局和筛选状态。
|
||||
|
||||
总窗口使用固定槽位布局,提供“单图布局”“左右布局”“四宫格布局”,每行分别显示 1、2、2 个固定位置,单图与左右布局每行约占一个可视区高度,四宫格每个可视区约显示两行。全部图表沿纵向连续排列,通过滚动查看;图表随槽位尺寸适配,末行按列数补齐空槽。窄窗口使用单图布局,调宽后恢复先前选择的布局。头部定位、布局和重置控件置于同一条浅底工具栏,控件保持等高,窄窗口允许自然换行。“快速定位图表”列出全部图表,选择后滚动到对应位置并聚焦目标;目标的位置栏短暂显示淡色背景和“已定位”,不使用整卡外框,避免高亮被滚动区域裁切。
|
||||
|
||||
@@ -32,9 +34,9 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
|
||||
|
||||
槽位映射依据稳定的子图表标识:同一图表实时更新时保留位置,删除图表后保留中间空槽并移除末尾空行,新图优先填补空槽,再追加位置与行。同一次任务查看期间,关闭并重新打开总窗口保留槽位与布局;任务、项目或执行代次切换后重新建立当前内容。交换位置不改变图表数据、对象选择或服务端的展示事件顺序。
|
||||
|
||||
工作台顶部的窗口列表入口列出已打开的总图表窗口、独立子图表和表格窗口,选择条目可将对应窗口置顶并定位到标题栏。列表中的“关闭全部窗口”统一收起当前打开的窗口,不删除已加载的图表、表格或任务结果;图表窗口可从原展示入口重新打开。
|
||||
工作台顶部的窗口列表入口仅列出已打开的独立子图表和表格浮窗,图表管理区不计入列表和窗口数量。选择条目可将对应浮窗置顶并定位到标题栏。“关闭全部窗口”收起独立浮窗,保留图表管理区和已加载结果;图表管理区可从原展示入口重新打开。
|
||||
|
||||
总窗口与子图表使用同一缩放交互:指针拖动窗口边缘时,图表按窗口实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择;松开后确认最终尺寸。拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸;标题栏的 Shift 加方向键继续用于逐步调整大小。
|
||||
拖动地图与分析区域的分隔条时,管理区图表随实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择。独立子图表仍支持拖动窗口边缘缩放;松开后确认最终尺寸,拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸,标题栏的 Shift 加方向键用于逐步调整大小。
|
||||
|
||||
只有正在跟随的运行中调查新收到 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 { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
|
||||
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
|
||||
import { useChartWorkspace } from "../workspace/chart-workspace-context";
|
||||
|
||||
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
|
||||
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
|
||||
@@ -25,6 +26,7 @@ export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle,
|
||||
onCloseChart: (id: string) => void;
|
||||
references: (ids: string[]) => ReactNode;
|
||||
}) {
|
||||
const dock = useChartWorkspace();
|
||||
const entries = useMemo<ManagedChart[]>(() => [
|
||||
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
|
||||
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
|
||||
@@ -54,13 +56,13 @@ export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle,
|
||||
const count = entries.length;
|
||||
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
|
||||
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent dock={dock}
|
||||
// A session gets one automatic request. Updates never revoke a user's dismissal.
|
||||
openRequestKey={autoOpen ? "live-charts" : undefined}>
|
||||
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||
<div className="shrink-0 space-y-2 border-b border-border pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大</p></div>
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖动交换位置,支持单图放大</p></div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
|
||||
</div>
|
||||
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
History,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle
|
||||
@@ -61,6 +63,7 @@ const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||
const MINIMUM_REFRESH_FEEDBACK_MS = 3_000;
|
||||
|
||||
export type ScheduledConditionFeedPresentation =
|
||||
| "workspace"
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
@@ -82,6 +85,7 @@ type ScheduledConditionFeedProps = {
|
||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||
onExpandAgent: () => void;
|
||||
onClose?: () => void;
|
||||
reserveAgentRail?: boolean;
|
||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
|
||||
@@ -124,6 +128,7 @@ export function ScheduledConditionFeed({
|
||||
onSelectedConditionChange,
|
||||
onExpandAgent,
|
||||
onClose,
|
||||
reserveAgentRail = false,
|
||||
onLoadHistorySession,
|
||||
onSubmitPrompt,
|
||||
onOpenTaskCollaboration,
|
||||
@@ -160,8 +165,10 @@ export function ScheduledConditionFeed({
|
||||
const desktopDock = presentation === "desktop-dock";
|
||||
const compactPresentation = mobileSheet || desktopDock;
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const workspace = presentation === "workspace";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const selectedConditionId = controlledSelectedConditionId === undefined
|
||||
? uncontrolledSelectedConditionId : controlledSelectedConditionId;
|
||||
const conditionsByCategory = useMemo(
|
||||
() => groupConditionsByTaskCategory(conditions),
|
||||
[conditions]
|
||||
@@ -219,17 +226,14 @@ export function ScheduledConditionFeed({
|
||||
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
|
||||
const selectedCondition =
|
||||
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||
timelineConditions[0] ??
|
||||
null;
|
||||
const showCompactDetail = expanded && Boolean(selectedCondition);
|
||||
const focusConditionId = focusRequest?.conditionId;
|
||||
const focusRequestId = focusRequest?.requestId;
|
||||
const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null);
|
||||
const updateExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (controlledExpanded === undefined) {
|
||||
setUncontrolledExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
if (controlledExpanded === undefined) setUncontrolledExpanded(nextExpanded);
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[controlledExpanded, onExpandedChange]
|
||||
@@ -256,10 +260,7 @@ export function ScheduledConditionFeed({
|
||||
}, [taskFilter, taskFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expanded) return;
|
||||
// A controlled task selection survives panel remounts and detail-loading windows.
|
||||
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
|
||||
if (selected) {
|
||||
@@ -308,9 +309,6 @@ export function ScheduledConditionFeed({
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(
|
||||
selectedConditionId ?? timelineConditions[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
|
||||
@@ -318,9 +316,7 @@ export function ScheduledConditionFeed({
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
setStatusFilter(CONDITION_FILTER_ALL);
|
||||
setRetainedScheduledCondition(null);
|
||||
updateSelectedConditionId(
|
||||
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
|
||||
);
|
||||
updateSelectedConditionId(null);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
@@ -360,9 +356,7 @@ export function ScheduledConditionFeed({
|
||||
);
|
||||
}
|
||||
updateSelectedConditionId(conditionId);
|
||||
if (!expanded) {
|
||||
updateExpanded(true);
|
||||
}
|
||||
if (!expanded) updateExpanded(true);
|
||||
}
|
||||
|
||||
function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) {
|
||||
@@ -427,22 +421,20 @@ export function ScheduledConditionFeed({
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
data-task-category={taskCategory}
|
||||
data-presentation={presentation}
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
mobileSheet
|
||||
workspace
|
||||
? "surface-dock flex h-full w-full min-w-0 flex-col border-l border-[var(--surface-divider)] pb-16"
|
||||
: mobileSheet
|
||||
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||
: cn(
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? cn(
|
||||
"rounded-2xl",
|
||||
expanded
|
||||
? "h-[calc(100dvh-8rem)]"
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
? cn("rounded-2xl", expanded ? "h-[calc(100dvh-8rem)]" : "max-h-[calc(100dvh-8rem)]")
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded && !desktopDock
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
@@ -453,7 +445,8 @@ export function ScheduledConditionFeed({
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2.5 py-2",
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME,
|
||||
workspace && reserveAgentRail && "pl-20"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -486,18 +479,13 @@ export function ScheduledConditionFeed({
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
data-condition-dock-toggle
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? "收起工况任务" : "展开工况任务"}
|
||||
onClick={handleToggleExpanded}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center text-[var(--ink-muted)] transition hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
|
||||
"rounded-lg"
|
||||
)}
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-[var(--ink-muted)] transition-colors hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)] active:scale-95 focus-visible:outline-2 focus-visible:outline-[var(--action-blue)]"
|
||||
>
|
||||
<ChevronDown
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
/>
|
||||
{expanded ? <Minimize2 size={17} aria-hidden="true" /> : <Maximize2 size={17} aria-hidden="true" />}
|
||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||
</button>
|
||||
{mobileSheet && onClose ? (
|
||||
@@ -513,8 +501,7 @@ export function ScheduledConditionFeed({
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{expanded ? (
|
||||
<TaskCategorySwitch
|
||||
{expanded ? <TaskCategorySwitch
|
||||
activeCategory={taskCategory}
|
||||
counts={{
|
||||
scheduled: conditionsByCategory.scheduled.length,
|
||||
@@ -522,29 +509,22 @@ export function ScheduledConditionFeed({
|
||||
appointment: conditionsByCategory.appointment.length
|
||||
}}
|
||||
onChange={handleTaskCategoryChange}
|
||||
/>
|
||||
) : null}
|
||||
/> : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
compactPresentation
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
: expanded ? "scheduled-feed-layout-expanded flex-1" : "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter h-full"
|
||||
: "pointer-events-none opacity-0"
|
||||
? showCompactDetail ? "scheduled-feed-detail-enter h-full w-full" : "hidden"
|
||||
: expanded ? "scheduled-feed-detail-enter h-full" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
@@ -571,12 +551,19 @@ export function ScheduledConditionFeed({
|
||||
onReportViewed={onReportViewed}
|
||||
focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined}
|
||||
/>
|
||||
) : expanded && !compactPresentation ? (
|
||||
<div className={cn("flex h-full items-center justify-center p-6 text-center", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">请选择工况任务</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">从右侧列表选择条目后查看详情。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||
compactPresentation && (showCompactDetail ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
@@ -720,9 +707,7 @@ function ConditionTimelinePanel({
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-full max-h-none"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
: expanded ? "h-full max-h-none" : "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** Move the live panel without resetting filters, report state or pending input. */
|
||||
export function ScheduledConditionWorkspace({
|
||||
docked, dockTarget, hidden, children
|
||||
}: {
|
||||
docked: boolean;
|
||||
dockTarget: RefObject<HTMLDivElement | null>;
|
||||
hidden: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const floating = useRef<HTMLDivElement>(null);
|
||||
const restoreFocus = useRef(false);
|
||||
const [host] = useState(() => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "h-full min-h-0 min-w-0";
|
||||
return element;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const target = docked ? dockTarget.current : floating.current;
|
||||
target?.append(host);
|
||||
// Moving the host can drop keyboard focus. Restore the corresponding control.
|
||||
if (restoreFocus.current && !hidden) host.querySelector<HTMLButtonElement>("[data-condition-dock-toggle]")?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
restoreFocus.current = host.contains(document.activeElement);
|
||||
host.remove();
|
||||
};
|
||||
}, [dockTarget, docked, hidden, host]);
|
||||
|
||||
return <>
|
||||
<div ref={floating}
|
||||
className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]"
|
||||
style={hidden || docked ? { display: "none" } : undefined} />
|
||||
{createPortal(children, host)}
|
||||
</>;
|
||||
}
|
||||
@@ -92,13 +92,15 @@ async function waitForChart(
|
||||
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
|
||||
// A deliberate collapse defers rendering, rather than failing a valid command.
|
||||
if (panel?.dataset.investigationCollapsed === "true") return;
|
||||
const mapHidden = Boolean(panel?.closest('[aria-hidden="true"]'));
|
||||
const element = [
|
||||
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
|
||||
].find(
|
||||
(item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]')
|
||||
(item) => item.dataset.investigationChart === chartId && (mapHidden || !item.closest('[aria-hidden="true"]'))
|
||||
);
|
||||
if (element && element.getBoundingClientRect().height > 0) {
|
||||
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]");
|
||||
if (element && (mapHidden || element.getBoundingClientRect().height > 0)) {
|
||||
// A focused workspace deliberately hides the map's inline chart copy.
|
||||
const viewport = mapHidden ? null : panel?.querySelector<HTMLElement>("[data-investigation-content]");
|
||||
if (viewport) {
|
||||
const plot = element.querySelector("svg[role=img]") ?? element;
|
||||
const overflow =
|
||||
@@ -298,18 +300,19 @@ export function useInvestigationPresentation(options: Options) {
|
||||
update: apply,
|
||||
rendering: () => store.selectedId === null,
|
||||
cameraAllowed: () => !cameraPausedRef.current,
|
||||
waitForChart: (chartId, nextSignal) =>
|
||||
waitForChart(
|
||||
waitForChart: (chartId, nextSignal) => {
|
||||
requestChartWindow(origin);
|
||||
return waitForChart(
|
||||
chartId,
|
||||
nextSignal,
|
||||
valuesRef.current.scope ?? "investigation",
|
||||
() => store.selectedId === null
|
||||
),
|
||||
);
|
||||
},
|
||||
frame
|
||||
})
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (command.action === "open_chart") requestChartWindow(origin);
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
|
||||
@@ -50,10 +50,12 @@ describe("workbench responsive layout", () => {
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(true));
|
||||
expect(result.current.conditionFeedExpanded).toBe(true);
|
||||
expect(result.current.conditionFeedDocked).toBe(true);
|
||||
expect(collapseAgentPanel).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(false));
|
||||
expect(result.current.conditionFeedExpanded).toBe(false);
|
||||
expect(result.current.conditionFeedDocked).toBe(false);
|
||||
expect(expandAgentPanel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -76,6 +78,10 @@ describe("workbench responsive layout", () => {
|
||||
expect(result.current.mobileSheetSnap).toBe("half");
|
||||
expect(onClearActiveTool).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => result.current.handleConditionExpandedChange(true));
|
||||
expect(result.current.conditionFeedExpanded).toBe(true);
|
||||
expect(result.current.conditionFeedDocked).toBe(false);
|
||||
|
||||
act(() => result.current.toggleConditionFeedForViewport());
|
||||
expect(result.current.mobileSheet).toBeNull();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
|
||||
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
|
||||
import { CONDITION_PANE_MIN_WIDTH, type ArtifactRequestedView } from "../workspace/workspace-model";
|
||||
|
||||
const CONDITION_FEED_EXIT_MS = 170;
|
||||
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
|
||||
@@ -33,6 +34,8 @@ export function useWorkbenchResponsiveLayout({
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [conditionFeedDocked, setConditionFeedDocked] = useState(false);
|
||||
const [conditionView, setConditionView] = useState<ArtifactRequestedView>("map_split");
|
||||
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
|
||||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||||
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||||
@@ -87,7 +90,11 @@ export function useWorkbenchResponsiveLayout({
|
||||
|
||||
function openAgentPanelForViewport() {
|
||||
if (isLargeScreen) {
|
||||
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
if (conditionFeedDocked && viewportWidth - agentPanelWidth < CONDITION_PANE_MIN_WIDTH) {
|
||||
setConditionFeedDocked(false);
|
||||
setConditionFeedExpanded(false);
|
||||
}
|
||||
if (!conditionFeedDocked && conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
setConditionFeedExpanded(false);
|
||||
setAgentCollapsedForCondition(false);
|
||||
}
|
||||
@@ -100,10 +107,11 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
|
||||
setAgentCollapsedForCondition(agentPanelOpen);
|
||||
collapseAgentPanel();
|
||||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||||
if (isLargeScreen) {
|
||||
setConditionFeedDocked(nextExpanded);
|
||||
if (nextExpanded) setConditionView("map_split");
|
||||
}
|
||||
if (!nextExpanded && agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
expandAgentPanel();
|
||||
}
|
||||
@@ -111,22 +119,22 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||||
return;
|
||||
if (isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && agentPanelOpen
|
||||
&& viewportWidth - agentPanelWidth < CONDITION_PANE_MIN_WIDTH) {
|
||||
setAgentCollapsedForCondition(true);
|
||||
collapseAgentPanel();
|
||||
}
|
||||
}, [agentPanelOpen, agentPanelWidth, collapseAgentPanel, conditionFeedDocked, isLargeScreen, shouldShowConditionFeed, viewportWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) return;
|
||||
setConditionFeedExpanded(false);
|
||||
setConditionFeedDocked(false);
|
||||
if (agentCollapsedForCondition) {
|
||||
setAgentCollapsedForCondition(false);
|
||||
expandAgentPanel();
|
||||
}
|
||||
}, [
|
||||
agentCollapsedForCondition,
|
||||
conditionFeedExpanded,
|
||||
expandAgentPanel,
|
||||
isLargeScreen,
|
||||
shouldShowConditionFeed
|
||||
]);
|
||||
}, [agentCollapsedForCondition, conditionFeedExpanded, expandAgentPanel, isLargeScreen, shouldShowConditionFeed]);
|
||||
|
||||
function toggleConditionFeedForViewport() {
|
||||
onClearActiveTool();
|
||||
@@ -161,14 +169,16 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
const leftPanelOpen = isLargeScreen && agentPanelOpen;
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||||
const rightPanelOpen = isLargeScreen && (devPanelOpen || (shouldShowConditionFeed && !conditionFeedDocked));
|
||||
const rightPanelExpanded =
|
||||
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||||
isLargeScreen && shouldShowConditionFeed && !conditionFeedDocked && conditionFeedExpanded && !devPanelOpen;
|
||||
|
||||
return {
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedDocked,
|
||||
conditionView,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
isLargeScreen,
|
||||
@@ -181,6 +191,7 @@ export function useWorkbenchResponsiveLayout({
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setConditionView,
|
||||
setMobileSheetSnap,
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
|
||||
@@ -44,6 +44,11 @@ import { InvestigationPresentationPanel } from "./components/investigation-prese
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { ScheduledConditionWorkspace } from "./components/scheduled-condition-workspace";
|
||||
import { CONDITION_MAP_SPLIT_MIN_WIDTH } from "./workspace/workspace-model";
|
||||
import { ARTIFACT_MAP_SPLIT_MIN_WIDTH, ARTIFACT_PANE_MIN_WIDTH } from "./workspace/workspace-model";
|
||||
import { ChartWorkspaceProvider } from "./workspace/chart-workspace-provider";
|
||||
import { useChartWorkspace } from "./workspace/chart-workspace-context";
|
||||
import type { InvestigationDiscussion } from "./api/investigation-report";
|
||||
import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
@@ -136,7 +141,7 @@ type MapWorkbenchPageProps = {
|
||||
projectSelection?: WorkbenchProjectSelection;
|
||||
};
|
||||
export function MapWorkbenchPage(props: MapWorkbenchPageProps) {
|
||||
return <ReadingWorkspaceProvider><MapWorkbenchContent {...props} /></ReadingWorkspaceProvider>;
|
||||
return <ReadingWorkspaceProvider><ChartWorkspaceProvider><MapWorkbenchContent {...props} /></ChartWorkspaceProvider></ReadingWorkspaceProvider>;
|
||||
}
|
||||
function MapWorkbenchContent({
|
||||
user = WORKBENCH_USER,
|
||||
@@ -145,6 +150,7 @@ function MapWorkbenchContent({
|
||||
projectSelection
|
||||
}: MapWorkbenchPageProps) {
|
||||
const readingWorkspace = useReadingWorkspace();
|
||||
const chartWorkspace = useChartWorkspace()!;
|
||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
|
||||
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
|
||||
@@ -269,8 +275,10 @@ function MapWorkbenchContent({
|
||||
agentPanelWidth,
|
||||
closeMobileSheet,
|
||||
conditionFeedExpanded,
|
||||
conditionFeedDocked,
|
||||
conditionView,
|
||||
conditionFeedMounted,
|
||||
handleConditionExpandedChange,
|
||||
handleConditionExpandedChange: updateConditionExpanded,
|
||||
isLargeScreen,
|
||||
leftPanelOpen,
|
||||
mobileSheet,
|
||||
@@ -281,6 +289,7 @@ function MapWorkbenchContent({
|
||||
rightPanelExpanded,
|
||||
rightPanelOpen,
|
||||
setAgentPanelWidth,
|
||||
setConditionView,
|
||||
setMobileSheetSnap,
|
||||
shouldShowConditionFeed,
|
||||
toggleConditionFeedForViewport,
|
||||
@@ -295,6 +304,20 @@ function MapWorkbenchContent({
|
||||
});
|
||||
const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
|
||||
const chartCollapsedAgent = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isLargeScreen && chartWorkspace.activeId && leftPanelOpen && businessWorkspaceWidth < ARTIFACT_PANE_MIN_WIDTH) {
|
||||
chartCollapsedAgent.current = true;
|
||||
collapseAgentPanel();
|
||||
} else if (!chartWorkspace.activeId && chartCollapsedAgent.current) {
|
||||
chartCollapsedAgent.current = false;
|
||||
expandAgentPanel();
|
||||
}
|
||||
}, [businessWorkspaceWidth, chartWorkspace.activeId, collapseAgentPanel, expandAgentPanel, isLargeScreen, leftPanelOpen]);
|
||||
const handleConditionExpandedChange = useCallback((expanded: boolean) => {
|
||||
if (expanded) chartWorkspace.deactivate();
|
||||
updateConditionExpanded(expanded);
|
||||
}, [chartWorkspace, updateConditionExpanded]);
|
||||
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
|
||||
const artifactActive = artifactWorkspace.activeArtifact !== null;
|
||||
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
|
||||
@@ -356,6 +379,7 @@ function MapWorkbenchContent({
|
||||
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
|
||||
|
||||
function openAgentWorkspaceForViewport() {
|
||||
if (!isLargeScreen || (chartWorkspace.activeId && viewportWidth - agentPanelWidth < ARTIFACT_PANE_MIN_WIDTH)) chartWorkspace.deactivate();
|
||||
openAgentPanelForViewport();
|
||||
}
|
||||
|
||||
@@ -417,10 +441,24 @@ function MapWorkbenchContent({
|
||||
}
|
||||
|
||||
function openConditionWorkspaceForViewport() {
|
||||
chartWorkspace.deactivate();
|
||||
setSelectedConditionId(null);
|
||||
setConditionFocusRequest(null);
|
||||
openConditionFeedForViewport();
|
||||
}
|
||||
|
||||
function toggleConditionWorkspaceForViewport() {
|
||||
if (chartWorkspace.activeId) {
|
||||
chartWorkspace.deactivate();
|
||||
if (!shouldShowConditionFeed) openConditionWorkspaceForViewport();
|
||||
else if (!isLargeScreen) openConditionFeedForViewport();
|
||||
handleConditionExpandedChange(true);
|
||||
return;
|
||||
}
|
||||
if (isLargeScreen ? !shouldShowConditionFeed : mobileSheet !== "condition") {
|
||||
setSelectedConditionId(null);
|
||||
setConditionFocusRequest(null);
|
||||
}
|
||||
toggleConditionFeedForViewport();
|
||||
}
|
||||
|
||||
@@ -517,8 +555,15 @@ function MapWorkbenchContent({
|
||||
: { ...padding, bottom: Math.max(padding.bottom, 420) });
|
||||
}
|
||||
});
|
||||
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
|
||||
function showMapForLocation() {
|
||||
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
|
||||
else if (chartWorkspace.activeId) chartWorkspace.setView(isLargeScreen && businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "map_only");
|
||||
else if (isLargeScreen && conditionFeedDocked) {
|
||||
setConditionView(businessWorkspaceWidth >= CONDITION_MAP_SPLIT_MIN_WIDTH ? "map_split" : "map_only");
|
||||
}
|
||||
}
|
||||
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
|
||||
showMapForLocation();
|
||||
return presentation.locateTargets(targets).then(() => true).catch((error) => {
|
||||
showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" });
|
||||
return false;
|
||||
@@ -838,6 +883,7 @@ function MapWorkbenchContent({
|
||||
|
||||
function handleOpenLatestAnalysis() {
|
||||
if (!latestAnalysisArtifact || !latestAnalysisAvailable) return;
|
||||
chartWorkspace.deactivate();
|
||||
artifactWorkspace.openArtifact(latestAnalysisArtifact);
|
||||
}
|
||||
|
||||
@@ -1285,7 +1331,7 @@ function MapWorkbenchContent({
|
||||
} : undefined,
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onLocateAnswerFeatures: async (target) => {
|
||||
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
|
||||
showMapForLocation();
|
||||
return chatPresentation.executeCommand({ action: "locate_features", layer: target.layer, feature_ids: target.feature_ids }, new AbortController().signal, "chat");
|
||||
},
|
||||
onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt,
|
||||
@@ -1294,8 +1340,10 @@ function MapWorkbenchContent({
|
||||
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
|
||||
};
|
||||
|
||||
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active);
|
||||
const mobileSheetSuspended = Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
|
||||
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active) || Boolean(chartWorkspace.activeId);
|
||||
const conditionDockTarget = useRef<HTMLDivElement>(null);
|
||||
const conditionDockActive = isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && !conditionFeedSuspended;
|
||||
const mobileSheetSuspended = Boolean(chartWorkspace.activeId) || Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -1369,6 +1417,7 @@ function MapWorkbenchContent({
|
||||
onClose={() => setAnalysisResultsPanelOpen(false)}
|
||||
getAccessToken={getAccessToken}
|
||||
onOpenArtifact={(artifact) => {
|
||||
chartWorkspace.deactivate();
|
||||
artifactWorkspace.openArtifact(artifact);
|
||||
setAnalysisResultsPanelOpen(false);
|
||||
}}
|
||||
@@ -1400,10 +1449,18 @@ function MapWorkbenchContent({
|
||||
viewportWidth={viewportWidth}
|
||||
conditionExpanded={rightPanelExpanded}
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onExpandPanel={openAgentWorkspaceForViewport}
|
||||
onWidthCommit={setAgentPanelWidth}
|
||||
>
|
||||
<WorkbenchMainFrame
|
||||
chartDock={{ window: chartWorkspace, splitAvailable: isLargeScreen && businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH, reserveAgentRail: !leftPanelOpen }}
|
||||
conditionDock={{
|
||||
active: conditionDockActive,
|
||||
splitAvailable: businessWorkspaceWidth >= CONDITION_MAP_SPLIT_MIN_WIDTH,
|
||||
target: conditionDockTarget,
|
||||
view: conditionView,
|
||||
onSetView: setConditionView
|
||||
}}
|
||||
activeArtifact={artifactWorkspace.activeArtifact}
|
||||
pendingArtifact={artifactWorkspace.pendingArtifact}
|
||||
surfaceMode={artifactWorkspace.surfaceMode}
|
||||
@@ -1523,9 +1580,10 @@ function MapWorkbenchContent({
|
||||
</WorkbenchAgentPanels>
|
||||
|
||||
{conditionFeedMounted ? (
|
||||
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={conditionFeedSuspended ? { display: "none" } : undefined}>
|
||||
<ScheduledConditionWorkspace docked={conditionDockActive} dockTarget={conditionDockTarget} hidden={conditionFeedSuspended}>
|
||||
<ScheduledConditionFeed
|
||||
presentation="desktop-floating"
|
||||
presentation={conditionDockActive ? "workspace" : "desktop-floating"}
|
||||
reserveAgentRail={!leftPanelOpen && (conditionView === "focus" || businessWorkspaceWidth < CONDITION_MAP_SPLIT_MIN_WIDTH)}
|
||||
conditions={scheduledConditions}
|
||||
dataSource={runtimeDataSource}
|
||||
workOrderDataSource={workOrderDataSource}
|
||||
@@ -1558,7 +1616,7 @@ function MapWorkbenchContent({
|
||||
onReportViewed={operationalTasks.markReportRead}
|
||||
onRefresh={workbenchSync.refresh}
|
||||
/>
|
||||
</div>
|
||||
</ScheduledConditionWorkspace>
|
||||
) : null}
|
||||
|
||||
{devPanelEnabled && devPanelOpen ? (
|
||||
@@ -1638,7 +1696,7 @@ function MapWorkbenchContent({
|
||||
type="button"
|
||||
aria-label="打开 Agent 面板"
|
||||
title="打开 Agent 面板"
|
||||
onClick={openAgentPanelForViewport}
|
||||
onClick={openAgentWorkspaceForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
|
||||
>
|
||||
<AgentPersona className="pointer-events-none h-10 w-10" state={agent.personaState} />
|
||||
@@ -1654,7 +1712,7 @@ function MapWorkbenchContent({
|
||||
type="button"
|
||||
aria-label="打开工况任务"
|
||||
title="打开工况任务"
|
||||
onClick={toggleConditionFeedForViewport}
|
||||
onClick={toggleConditionWorkspaceForViewport}
|
||||
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
|
||||
>
|
||||
<CalendarClock size={20} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
|
||||
|
||||
export const ChartWorkspaceContext = createContext<FloatingWindowDock | null>(null);
|
||||
export const useChartWorkspace = () => useContext(ChartWorkspaceContext);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
|
||||
import { ChartWorkspaceContext } from "./chart-workspace-context";
|
||||
|
||||
export function ChartWorkspaceProvider({ children }: { children: ReactNode }) {
|
||||
const target = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<{ activeId: string | null; view: FloatingWindowDock["view"] }>({ activeId: null, view: "map_split" });
|
||||
const activate = useCallback((id: string) => {
|
||||
setState((current) => current.activeId === id ? current : { activeId: id, view: "map_split" });
|
||||
}, []);
|
||||
const deactivate = useCallback((id?: string) => {
|
||||
setState((current) => id && current.activeId !== id ? current : { ...current, activeId: null });
|
||||
}, []);
|
||||
const setView = useCallback((view: FloatingWindowDock["view"]) => setState((current) => ({ ...current, view })), []);
|
||||
const value = useMemo(() => ({ ...state, target, activate, deactivate, setView }), [state, activate, deactivate, setView]);
|
||||
return <ChartWorkspaceContext.Provider value={value}>{children}</ChartWorkspaceContext.Provider>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export function WorkbenchContentSplit({
|
||||
split,
|
||||
showMap,
|
||||
minPaneWidth,
|
||||
minDocumentWidth = minPaneWidth,
|
||||
onMapSizeChange
|
||||
}: {
|
||||
map: ReactNode;
|
||||
@@ -16,6 +17,7 @@ export function WorkbenchContentSplit({
|
||||
split: boolean;
|
||||
showMap: boolean;
|
||||
minPaneWidth: number;
|
||||
minDocumentWidth?: number;
|
||||
onMapSizeChange: (percentage: number) => void;
|
||||
}) {
|
||||
const group = useGroupRef();
|
||||
@@ -24,7 +26,7 @@ export function WorkbenchContentSplit({
|
||||
group.current?.setLayout(
|
||||
split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 }
|
||||
);
|
||||
}, [group, split, showMap, minPaneWidth]);
|
||||
}, [group, split, showMap, minPaneWidth, minDocumentWidth]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
@@ -56,7 +58,7 @@ export function WorkbenchContentSplit({
|
||||
<ResizablePanel
|
||||
id="document"
|
||||
defaultSize={showMap ? (split ? "50%" : "0%") : "100%"}
|
||||
minSize={split ? minPaneWidth : showMap ? 0 : "100%"}
|
||||
minSize={split ? minDocumentWidth : showMap ? 0 : "100%"}
|
||||
maxSize={!split && showMap ? 0 : "100%"}
|
||||
className="relative h-full min-h-0 min-w-0"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useReadingWorkspace, type ReadingDocument } from "@/shared/ui/reading-workspace-context";
|
||||
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
|
||||
@@ -10,7 +10,8 @@ import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
import { ArtifactPeekBar } from "./artifact-peek-bar";
|
||||
import { WorkbenchContentSplit } from "./workbench-content-split";
|
||||
import { ARTIFACT_PANE_MIN_WIDTH } from "./workspace-model";
|
||||
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
|
||||
import { ARTIFACT_PANE_MIN_WIDTH, CONDITION_PANE_MIN_WIDTH } from "./workspace-model";
|
||||
import {
|
||||
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
|
||||
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
|
||||
@@ -32,6 +33,14 @@ type WorkbenchMainFrameProps = {
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
readingSplitAvailable?: boolean;
|
||||
chartDock?: { window: FloatingWindowDock; splitAvailable: boolean; reserveAgentRail: boolean };
|
||||
conditionDock?: {
|
||||
active: boolean;
|
||||
splitAvailable: boolean;
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
view: ArtifactRequestedView;
|
||||
onSetView: (view: ArtifactRequestedView) => void;
|
||||
};
|
||||
onOpenPendingArtifact: () => void;
|
||||
onDestroyPendingArtifact: () => void;
|
||||
onSetArtifactView: (view: ArtifactRequestedView) => void;
|
||||
@@ -50,6 +59,8 @@ export function WorkbenchMainFrame({
|
||||
surfaceMode: artifactSurfaceMode,
|
||||
mapSplitAvailable,
|
||||
readingSplitAvailable = false,
|
||||
chartDock,
|
||||
conditionDock,
|
||||
onOpenPendingArtifact,
|
||||
onDestroyPendingArtifact,
|
||||
onSetArtifactView,
|
||||
@@ -59,19 +70,30 @@ export function WorkbenchMainFrame({
|
||||
const reading = useReadingWorkspace();
|
||||
const lastReading = useRef<ReadingDocument | null>(null);
|
||||
useEffect(() => { if (reading?.active) lastReading.current = reading.active; }, [reading?.active]);
|
||||
const chartActive = Boolean(chartDock?.window.activeId && !reading?.active);
|
||||
const dock = reading?.active ? null : chartActive && chartDock
|
||||
? { ...chartDock.window, splitAvailable: chartDock.splitAvailable, onSetView: chartDock.window.setView }
|
||||
: conditionDock?.active ? conditionDock : null;
|
||||
const dockActive = Boolean(dock);
|
||||
const surfaceMode = reading?.active
|
||||
? reading.view === "map_split" && !readingSplitAvailable
|
||||
? "focus"
|
||||
: reading.view
|
||||
: dock
|
||||
? dock.view === "map_split" && !dock.splitAvailable ? "focus" : dock.view
|
||||
: artifactSurfaceMode;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const showMap = surfaceMode !== "focus";
|
||||
const showArtifact = Boolean(!reading?.active && activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean(activeArtifact && !reading?.active);
|
||||
const showArtifact = Boolean(!reading?.active && !dockActive && activeArtifact && surfaceMode !== "map_only");
|
||||
const showArtifactViewControl = Boolean((activeArtifact || dockActive) && !reading?.active);
|
||||
const mapOnly = surfaceMode === "map_only";
|
||||
const mapSplit = surfaceMode === "map_split";
|
||||
const [mapPercentage, setMapPercentage] = useState(50);
|
||||
const viewControlLabel = mapOnly
|
||||
const splitAvailable = dock?.splitAvailable ?? mapSplitAvailable;
|
||||
const viewControlLabel = dockActive
|
||||
? mapOnly ? chartActive ? "返回图表管理" : "返回工况任务"
|
||||
: mapSplit ? chartActive ? "专注图表" : "专注工况" : splitAvailable ? "地图对照" : "显示地图"
|
||||
: mapOnly
|
||||
? "返回分析成果"
|
||||
: mapSplit
|
||||
? "专注分析"
|
||||
@@ -94,7 +116,7 @@ export function WorkbenchMainFrame({
|
||||
<Dialog open={Boolean(reading?.active && surfaceMode === "focus")}
|
||||
onOpenChange={(open) => { if (!open) reading?.close(); }}>
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0"
|
||||
className={cn("workbench-main-frame pointer-events-none absolute inset-0 min-h-0", chartActive && "max-h-dvh")}
|
||||
data-testid="workbench-main-frame"
|
||||
data-timeline-mode={timelineMode}
|
||||
>
|
||||
@@ -107,6 +129,7 @@ export function WorkbenchMainFrame({
|
||||
split={mapSplit}
|
||||
showMap={showMap}
|
||||
minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH}
|
||||
minDocumentWidth={dockActive && !chartActive ? CONDITION_PANE_MIN_WIDTH : undefined}
|
||||
onMapSizeChange={setMapPercentage}
|
||||
map={
|
||||
<section
|
||||
@@ -124,6 +147,15 @@ export function WorkbenchMainFrame({
|
||||
}
|
||||
document={
|
||||
<>
|
||||
<div
|
||||
ref={conditionDock?.target}
|
||||
id="condition-workspace"
|
||||
className={cn("absolute inset-x-0 bottom-0 top-14 min-h-0 min-w-0", (!dockActive || chartActive || mapOnly) && "hidden")}
|
||||
/>
|
||||
<div ref={chartDock?.window.target} id="chart-workspace"
|
||||
className={cn("absolute inset-x-0 bottom-0 top-14 min-h-0 min-w-0",
|
||||
chartActive && surfaceMode === "focus" && chartDock?.reserveAgentRail && "lg:pl-[84px]",
|
||||
(!chartActive || mapOnly) && "hidden")} />
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
@@ -179,7 +211,7 @@ export function WorkbenchMainFrame({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{pendingArtifact && !reading?.active ? (
|
||||
{pendingArtifact && !reading?.active && !dockActive ? (
|
||||
<ArtifactPeekBar
|
||||
artifact={pendingArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
@@ -205,9 +237,9 @@ export function WorkbenchMainFrame({
|
||||
type="button"
|
||||
aria-label={viewControlLabel}
|
||||
onClick={() =>
|
||||
onSetArtifactView(
|
||||
(dock?.onSetView ?? onSetArtifactView)(
|
||||
surfaceMode === "focus"
|
||||
? mapSplitAvailable
|
||||
? splitAvailable
|
||||
? "map_split"
|
||||
: "map_only"
|
||||
: "focus"
|
||||
@@ -243,12 +275,12 @@ export function WorkbenchMainFrame({
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0"
|
||||
className={cn("pointer-events-auto absolute left-3 z-20 mx-auto min-h-0", dockActive && !showMap && "hidden")}
|
||||
initial={false}
|
||||
animate={{
|
||||
bottom: timelineBottom,
|
||||
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
|
||||
right: 12
|
||||
right: dockActive && mapSplit ? `calc(${100 - mapPercentage}% + 12px)` : 12
|
||||
}}
|
||||
transition={timelineTransition}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { FeatureCollection, Geometry } from "geojson";
|
||||
|
||||
export const ARTIFACT_PANE_MIN_WIDTH = 600;
|
||||
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1;
|
||||
export const CONDITION_PANE_MIN_WIDTH = 880;
|
||||
export const CONDITION_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH + CONDITION_PANE_MIN_WIDTH + 1;
|
||||
|
||||
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
|
||||
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { FloatingWindow } from "./floating-window";
|
||||
import { floatingWindowEntries, floatingWindowOrder } from "./floating-window-layer";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -35,3 +37,61 @@ it("retains manual opening, Escape dismissal and focus return", () => {
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible();
|
||||
});
|
||||
|
||||
function DockFixture({ multiple = false }: { multiple?: boolean }) {
|
||||
const target = useRef<HTMLDivElement>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activate = useCallback((id: string) => setActiveId(id), []);
|
||||
const deactivate = useCallback((id?: string) => setActiveId((current) => !id || current === id ? null : current), []);
|
||||
return <>
|
||||
<div ref={target} data-testid="workspace-slot" />
|
||||
<button onClick={() => deactivate()}>切换工作区</button>
|
||||
<FloatingWindow title="停靠分析" dock={{ target, activeId, activate, deactivate, view: "map_split", setView: () => undefined }}>
|
||||
{() => <input aria-label="分析备注" />}
|
||||
</FloatingWindow>
|
||||
{multiple ? <FloatingWindow title="停靠对比" dock={{ target, activeId, activate, deactivate, view: "map_split", setView: () => undefined }}>
|
||||
{() => <p>第二个来源</p>}
|
||||
</FloatingWindow> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
it("preserves the live document when a docked window is hidden and located again", () => {
|
||||
render(<DockFixture />);
|
||||
const trigger = screen.getByRole("button", { name: "放大查看 停靠分析" });
|
||||
fireEvent.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "停靠分析" });
|
||||
expect(screen.getByTestId("workspace-slot")).toContainElement(dialog);
|
||||
expect(dialog).toHaveAttribute("data-window-presentation", "docked");
|
||||
expect(floatingWindowEntries()).toEqual([]);
|
||||
expect(floatingWindowOrder()).toEqual([]);
|
||||
const editor = screen.getByLabelText("分析备注");
|
||||
fireEvent.change(editor, { target: { value: "保留核查说明" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换工作区" }));
|
||||
expect(dialog).not.toBeVisible();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByLabelText("分析备注")).toBe(editor);
|
||||
expect(editor).toHaveValue("保留核查说明");
|
||||
expect(dialog).toBeVisible();
|
||||
expect(floatingWindowEntries()).toEqual([]);
|
||||
expect(floatingWindowOrder()).toEqual([]);
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides inactive docking hosts so another source can occupy the same workspace", () => {
|
||||
render(<DockFixture multiple />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠分析" }));
|
||||
const first = screen.getByRole("dialog", { name: "停靠分析" });
|
||||
const editor = screen.getByLabelText("分析备注");
|
||||
fireEvent.change(editor, { target: { value: "保留第一个来源" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠对比" }));
|
||||
const second = screen.getByRole("dialog", { name: "停靠对比" });
|
||||
expect(first.parentElement).not.toBeVisible();
|
||||
expect(first.parentElement).toHaveAttribute("hidden");
|
||||
expect(second.parentElement).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠分析" }));
|
||||
expect(first.parentElement).toBeVisible();
|
||||
expect(second.parentElement).not.toBeVisible();
|
||||
expect(screen.getByLabelText("分析备注")).toBe(editor);
|
||||
expect(editor).toHaveValue("保留第一个来源");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
@@ -8,6 +8,14 @@ import { useFloatingWindowDrag } from "./use-floating-window-drag";
|
||||
import { useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
|
||||
|
||||
type Bounds = { x: number; y: number; width: number; height: number };
|
||||
export type FloatingWindowDock = {
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
activeId: string | null;
|
||||
view: "map_split" | "focus" | "map_only";
|
||||
activate: (id: string) => void;
|
||||
deactivate: (id?: string) => void;
|
||||
setView: (view: FloatingWindowDock["view"]) => void;
|
||||
};
|
||||
const resizeHandles: Record<ResizeEdge, React.CSSProperties> = {
|
||||
top: { top: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
|
||||
right: { right: 0, top: 18, bottom: 18, width: 8, cursor: "ew-resize" },
|
||||
@@ -28,7 +36,7 @@ function fitWindow(bounds: Bounds): Bounds {
|
||||
y: Math.max(top, Math.min(bounds.y, window.innerHeight - height - 8)) };
|
||||
}
|
||||
|
||||
export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, children }: {
|
||||
export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, dock, children }: {
|
||||
title: string;
|
||||
navigationTitle?: string;
|
||||
triggerLabel?: string;
|
||||
@@ -37,6 +45,8 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
fillContent?: boolean;
|
||||
/** A new display request opens the window without taking keyboard focus. */
|
||||
openRequestKey?: string;
|
||||
/** An optional layout host; individual windows remain floating by default. */
|
||||
dock?: FloatingWindowDock | null;
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
@@ -55,11 +65,23 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const heading = useRef<HTMLDivElement>(null);
|
||||
const focusOnOpen = useRef(false);
|
||||
const raise = () => raiseFloatingWindow(id);
|
||||
const [dockHost] = useState(() => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "h-full min-h-0 min-w-0";
|
||||
return element;
|
||||
});
|
||||
const docked = Boolean(dock);
|
||||
const focusWindow = () => {
|
||||
dock?.activate(id);
|
||||
if (!docked) raiseFloatingWindow(id);
|
||||
requestAnimationFrame(() => heading.current?.focus({ preventScroll: true }));
|
||||
};
|
||||
const raise = () => { if (!docked) raiseFloatingWindow(id); };
|
||||
const close = () => {
|
||||
setManuallyOpen(false);
|
||||
setRequest({ key: openRequestKey, dismissed: true });
|
||||
removeFloatingWindow(id);
|
||||
dock?.deactivate(id);
|
||||
if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement))
|
||||
trigger.current?.focus({ preventScroll: true });
|
||||
};
|
||||
@@ -67,14 +89,33 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
useLayoutEffect(() => {
|
||||
if (open) registerFloatingWindow({ id, title: navigationTitle ?? title,
|
||||
focus: () => { raiseFloatingWindow(id); heading.current?.focus({ preventScroll: true }); },
|
||||
if (open && !docked) registerFloatingWindow({ id, title: navigationTitle ?? title,
|
||||
focus: focusWindow,
|
||||
close: () => closeRef.current() });
|
||||
}, [id, open, title, navigationTitle]);
|
||||
});
|
||||
|
||||
const activateDock = dock?.activate;
|
||||
const deactivateDock = dock?.deactivate;
|
||||
const dockTarget = dock?.target;
|
||||
useLayoutEffect(() => {
|
||||
dockHost.hidden = Boolean(dock && dock.activeId !== id);
|
||||
}, [dock, dockHost, id]);
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !dockTarget) return;
|
||||
dockTarget.current?.append(dockHost);
|
||||
activateDock?.(id);
|
||||
return () => { dockHost.remove(); deactivateDock?.(id); };
|
||||
}, [id, open, dockHost, dockTarget, activateDock, deactivateDock]);
|
||||
useLayoutEffect(() => {
|
||||
if (open && focusOnOpen.current && (!dock || dock.activeId === id)) {
|
||||
heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
}
|
||||
}, [open, dock, id]);
|
||||
|
||||
useEffect(() => () => removeFloatingWindow(id), [id]);
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || docked) return;
|
||||
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
|
||||
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
|
||||
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
|
||||
@@ -89,8 +130,6 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
setBounds(fitWindow({ x: (window.innerWidth - initialSizeRef.current.width) / 2 + slot * 24, y,
|
||||
width: initialSizeRef.current.width, height: Math.min(initialSizeRef.current.height, window.innerHeight - y - 8) }));
|
||||
raiseFloatingWindow(id);
|
||||
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
const resize = () => {
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds((current) => {
|
||||
@@ -104,16 +143,16 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
window.removeEventListener("resize", resize);
|
||||
removeFloatingWindow(id);
|
||||
};
|
||||
}, [id, open]);
|
||||
}, [id, open, docked]);
|
||||
|
||||
const current = maximized
|
||||
? { x: 8, y: floatingWindowTopInset(), width: viewport.width - 16, height: viewport.height - floatingWindowTopInset() - 8 }
|
||||
: bounds;
|
||||
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
|
||||
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized && !docked, position: current,
|
||||
width: current.width, height: current.height,
|
||||
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
|
||||
|
||||
const startResize = useFloatingWindowResize({ enabled: open && !maximized, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
|
||||
const startResize = useFloatingWindowResize({ enabled: open && !maximized && !docked, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
|
||||
|
||||
return <>
|
||||
<Button ref={trigger} type="button" variant="ghost" size={triggerContent ? "sm" : "icon-sm"} className="shrink-0"
|
||||
@@ -124,24 +163,26 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
setMaximized(false);
|
||||
focusOnOpen.current = true;
|
||||
setManuallyOpen(true);
|
||||
} else heading.current?.focus({ preventScroll: true });
|
||||
} else focusWindow();
|
||||
raise();
|
||||
}}><Maximize2 size={15} aria-hidden />{triggerContent}</Button>
|
||||
{open ? createPortal(
|
||||
<div style={{ position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
|
||||
<div style={docked ? { position: "relative", width: "100%", height: "100%" } : { position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
|
||||
transform: `translate(${current.x}px, ${current.y}px)`, zIndex: order.indexOf(id) + 1 }}
|
||||
className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
|
||||
className={`workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden border border-border bg-background text-foreground ${docked ? "pb-28 lg:pb-14" : "rounded-xl shadow-lg"}`}
|
||||
role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window="" data-floating-window-id={id}
|
||||
data-window-presentation={docked ? "docked" : "floating"}
|
||||
hidden={Boolean(dock && dock.activeId !== id)}
|
||||
onPointerDownCapture={raise} onFocusCapture={raise}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
|
||||
}}>
|
||||
<div ref={heading} tabIndex={0}
|
||||
onPointerDown={startDrag}
|
||||
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
|
||||
className={`floating-window-heading flex min-h-12 shrink-0 ${docked ? "" : "cursor-move touch-none"} select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring`}
|
||||
aria-label={docked ? `${title}标题栏` : "窗口标题栏,可用方向键移动,Shift 加方向键调整大小"}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
if (docked || event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0;
|
||||
const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 16 : 0;
|
||||
@@ -151,10 +192,10 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
}}>
|
||||
<h2 id={titleId} className="min-w-0 break-words text-sm font-semibold leading-5">{title}</h2>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
|
||||
{!docked ? <Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
|
||||
title={maximized ? "还原窗口" : "最大化窗口"} onClick={() => setMaximized(!maximized)}>
|
||||
{maximized ? <Minimize2 size={15} aria-hidden /> : <Maximize2 size={15} aria-hidden />}
|
||||
</Button>
|
||||
</Button> : null}
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="关闭窗口" title="关闭窗口" onClick={close}>
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
@@ -165,13 +206,13 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
{children({ width: current.width - 26, height: current.height - 76 })}
|
||||
</div>
|
||||
</div>
|
||||
{!maximized ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
|
||||
{!maximized && !docked ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
|
||||
<div key={edge} aria-hidden data-window-resize-handle={edge}
|
||||
className={`absolute z-10 touch-none ${edge === "bottomRight" ? "floating-window-resize" : ""}`}
|
||||
style={style} onPointerDown={(event) => startResize(event, edge)}>
|
||||
{edge === "bottomRight" ? <span className="pointer-events-none absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> : null}
|
||||
</div>
|
||||
)) : null}
|
||||
</div>, floatingWindowHost()) : null}
|
||||
</div>, docked ? dockHost : floatingWindowHost()) : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
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) }));
|
||||
};
|
||||
await expect(panel).toHaveCSS("width", "880px");
|
||||
await expect(panel).toHaveAttribute("data-presentation", "workspace");
|
||||
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 panel.getByRole("button", { name: /预约任务/ }).click();
|
||||
@@ -378,7 +378,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
|
||||
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 testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({
|
||||
path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" });
|
||||
@@ -401,6 +401,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" });
|
||||
if (await mobileExpand.isVisible()) await mobileExpand.click();
|
||||
await mobilePanel.getByRole("button", { name: /调查任务/ }).click();
|
||||
await mobilePanel.getByText("水质投诉动态调查", { exact: true }).first().click();
|
||||
await mobilePanel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(mobilePanel.getByLabel("调查脉络", { exact: true }).locator("svg[id]")).toHaveCount(1);
|
||||
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) {
|
||||
await page.setViewportSize({ width: 1920, height: 1000 });
|
||||
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"
|
||||
}));
|
||||
@@ -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.height).toBeGreaterThan(dimensions.four.height);
|
||||
const heading = manager.getByLabel("窗口标题栏,可用方向键移动,Shift 加方向键调整大小", { exact: true });
|
||||
await heading.focus();
|
||||
for (let index = 0; index < 20; index++) await page.keyboard.press("Shift+ArrowUp");
|
||||
expect((await manager.boundingBox())!.height).toBeLessThanOrEqual(441);
|
||||
await page.setViewportSize({ width: 1920, height: 480 });
|
||||
await expect.poll(async () => (await manager.boundingBox())!.height).toBe(424);
|
||||
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
|
||||
await page.getByRole("option").filter({ hasText: "合成监测 P3" }).click();
|
||||
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 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(slots(manager)).toHaveCount(6);
|
||||
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
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"],
|
||||
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 mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
await expect(page.getByLabel("调查实时展示", { exact: true })).toContainText("实时跟随");
|
||||
await mock.emit(task, chart("P1"));
|
||||
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
|
||||
await expect(manager).toBeVisible();
|
||||
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(page.locator("[data-floating-window]")).toHaveCount(1);
|
||||
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);
|
||||
await first.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
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 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]) {
|
||||
@@ -61,6 +63,7 @@ test("one manager receives live charts and keeps independent enlarged children",
|
||||
const panel = page.getByLabel("调查实时展示", { exact: true });
|
||||
await expect(panel.locator('[data-investigation-chart="P3"]')).toHaveCount(1);
|
||||
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 expect(manager).toContainText("3 张图表");
|
||||
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(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
// Create a live request before returning to the same task later.
|
||||
await expect(panel).toContainText("实时跟随");
|
||||
await mock.emit(running, chart("P1", 23));
|
||||
await expect(manager).toBeVisible();
|
||||
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
const tasks = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await showInvestigationTasks(page);
|
||||
await tasks.getByText(completed.title, { exact: true }).first().click();
|
||||
await expect(panel.locator('[data-investigation-chart="P2"]')).toHaveCount(1);
|
||||
await showInvestigationMap(page);
|
||||
await expect(panel.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible();
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await showInvestigationMap(page);
|
||||
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
|
||||
await expect(manager).toContainText("2 张图表");
|
||||
await expect(manager.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible();
|
||||
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await showInvestigationTasks(page);
|
||||
await tasks.getByText(running.title, { exact: true }).first().click();
|
||||
await expect(panel.locator('[data-investigation-chart="P1"]')).toHaveCount(1);
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await showInvestigationMap(page);
|
||||
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
|
||||
await expect(manager).toBeVisible();
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
@@ -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";
|
||||
|
||||
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 } });
|
||||
for (const target of ["manager", "child"] as const) {
|
||||
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 tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成", { timeout: 20_000 });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const panel = page.getByLabel("本地预览展示", { exact: true });
|
||||
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
|
||||
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
|
||||
}));
|
||||
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);
|
||||
await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
||||
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 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.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.
|
||||
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);
|
||||
@@ -65,7 +89,7 @@ for (const target of ["manager", "child"] as const) {
|
||||
console.info("Chart resize profile", JSON.stringify(summary));
|
||||
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.height - before.height)).toBe(64);
|
||||
expect(Math.round(preview.height - before.height)).toBe(target === "manager" ? 0 : 64);
|
||||
expect(during).toBeGreaterThan(0);
|
||||
// Bound the expensive work on the same 4x CPU fixture used to reproduce the lag.
|
||||
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 session.send("Emulation.setCPUThrottlingRate", { rate: 1 });
|
||||
const chartSurface = target === "manager" ? manager.getByRole("group", { name: "合成全天压力对比", exact: true }) : popup;
|
||||
const selectedObject = chartSurface.getByRole("button", { name: /^切换对象 / }).nth(1);
|
||||
await selectedObject.click();
|
||||
const expectSelectedObject = await selectSecondObject(page, chartSurface);
|
||||
const hiddenSeries = chartSurface.getByLabel("显示的数据序列").getByRole("button").last();
|
||||
await hiddenSeries.click();
|
||||
await expect(selectedObject).toHaveAttribute("aria-pressed", "true");
|
||||
await expectSelectedObject();
|
||||
await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false");
|
||||
const selectedInstance = await chartSurface.locator(".echarts-for-react").first().getAttribute("_echarts_instance_");
|
||||
if (target === "child") {
|
||||
const beforeCancel = (await popup.boundingBox())!;
|
||||
const cancelHandle = popup.locator(".floating-window-resize");
|
||||
const cancelBox = (await cancelHandle.boundingBox())!;
|
||||
@@ -112,7 +136,7 @@ for (const target of ["manager", "child"] as const) {
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => popup.boundingBox()).toEqual(beforeCancel);
|
||||
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");
|
||||
const boundaryBox = (await cancelHandle.boundingBox())!;
|
||||
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())!;
|
||||
expect(Math.round(final.x + final.width)).toBe(1592);
|
||||
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(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);
|
||||
|
||||
@@ -192,13 +192,17 @@ for (const width of [1440, 375]) {
|
||||
await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 });
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
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 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 });
|
||||
const navigator = header.getByRole("button", { name: /窗口管理,当前/ });
|
||||
await navigator.click();
|
||||
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 expectPreservedOnHover(page, currentWindow, true);
|
||||
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 { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
test.describe("mobile workbench sheet", () => {
|
||||
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 }) => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 openConditionFeed(page);
|
||||
|
||||
@@ -84,15 +84,14 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
|
||||
const timeline = panel.getByTestId("scheduled-condition-timeline");
|
||||
const detailViewport = panel.locator(".scheduled-feed-detail-viewport");
|
||||
const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll");
|
||||
const collapsedPanelBox = await panel.boundingBox();
|
||||
const collapsedTimelineBox = await timeline.boundingBox();
|
||||
expect(collapsedPanelBox).not.toBeNull();
|
||||
expect(collapsedTimelineBox).not.toBeNull();
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "880px");
|
||||
await expect(panel).toHaveAttribute("data-presentation", "workspace");
|
||||
await expect(panel).toHaveCSS("backdrop-filter", "none");
|
||||
await timeline.locator("button[data-condition-id]").first().click();
|
||||
const expandedPanelBox = await panel.boundingBox();
|
||||
const expandedTimelineBox = await timeline.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(expandedDetailViewportBox).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(
|
||||
Math.round(collapsedTimelineBox!.width)
|
||||
);
|
||||
expect(Math.round(expandedTimelineBox!.x + expandedTimelineBox!.width)).toBe(
|
||||
Math.round(collapsedTimelineBox!.x + collapsedTimelineBox!.width)
|
||||
);
|
||||
expect(expandedTimelineBox!.y + expandedTimelineBox!.height).toBeLessThanOrEqual(
|
||||
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");
|
||||
const timelineScroll = timeline.locator(".scheduled-feed-scroll.flex-1");
|
||||
await page.mouse.move(0, 0);
|
||||
expect(await readScrollbarPresentation(detailScroll)).toEqual(
|
||||
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 page.keyboard.press("Enter");
|
||||
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(800);
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? Infinity).toBeLessThanOrEqual(880);
|
||||
await expect(panel).toHaveAttribute("data-presentation", "workspace");
|
||||
await expect(panel).toHaveCSS("width", "1024px");
|
||||
|
||||
const expandedConditionBox = await panel.boundingBox();
|
||||
const collapsedAgentBox = await page
|
||||
@@ -175,8 +169,9 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
|
||||
expect(expandedConditionBox).not.toBeNull();
|
||||
expect(collapsedAgentBox).not.toBeNull();
|
||||
expect(
|
||||
expandedConditionBox!.x - (collapsedAgentBox!.x + collapsedAgentBox!.width)
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
(await panel.getByRole("heading", { name: "工况任务", exact: true }).boundingBox())!.x
|
||||
- (collapsedAgentBox!.x + collapsedAgentBox!.width)
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await panel.getByRole("button", { name: "收起工况任务" }).click();
|
||||
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 });
|
||||
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 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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mockInvestigationPresentation } from "./support/mock-investigation-pres
|
||||
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
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);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
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 tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成");
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const panel = page.getByLabel("本地预览展示", { exact: true });
|
||||
await panel.getByRole("button", { name: "放大查看表格 窗口定位核查表", exact: true }).click();
|
||||
const table = page.getByRole("dialog", { name: "窗口定位核查表 · 表格", exact: true });
|
||||
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 });
|
||||
await manager.getByRole("button", { name: "放大查看 窗口定位压力趋势", exact: true }).click();
|
||||
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 search.fill("P2");
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(navigator).toHaveAttribute("aria-label", "窗口管理,当前 3 个窗口");
|
||||
await expect(navigator).toHaveAttribute("aria-label", "窗口管理,当前 2 个窗口");
|
||||
if (width === 1440) {
|
||||
const alignment = await navigator.evaluate((node) => {
|
||||
const label = node.children[1].getBoundingClientRect();
|
||||
@@ -55,19 +56,21 @@ for (const width of [1440, 375, 320]) {
|
||||
const tableBounds = await table.boundingBox();
|
||||
await navigator.click();
|
||||
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 expect(table.locator(".floating-window-heading")).toBeFocused();
|
||||
await expect(navigator).toHaveAttribute("aria-expanded", "false");
|
||||
expect(await table.boundingBox()).toEqual(tableBounds);
|
||||
await expect(search).toHaveValue("P2");
|
||||
const managerBounds = await manager.boundingBox();
|
||||
const childBounds = await child.boundingBox();
|
||||
await navigator.focus();
|
||||
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 expect(manager.locator(".floating-window-heading")).toBeFocused();
|
||||
expect(await manager.boundingBox()).toEqual(managerBounds);
|
||||
await expect(child.locator(".floating-window-heading")).toBeFocused();
|
||||
expect(await child.boundingBox()).toEqual(childBounds);
|
||||
await expect(child.getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await navigator.click();
|
||||
await header.getByRole("button", { name: "打开用户菜单", exact: true }).click();
|
||||
await expect(menu).toHaveCount(0);
|
||||
@@ -85,7 +88,8 @@ for (const width of [1440, 375, 320]) {
|
||||
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 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(header.getByRole("button", { name: "打开用户菜单", exact: true })).toBeFocused();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
|
||||
Reference in New Issue
Block a user