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

This commit is contained in:
2026-11-12 11:42:25 +08:00
parent 33f0b90a7d
commit 7a304a91a8
27 changed files with 766 additions and 201 deletions
@@ -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)}
</>;
}