Author SHA1 Message Date
jiang 28287a1447 fix: coordinate mobile workbench overlays
Generic Container CI/CD / test-build-publish (push) Successful in 1m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m40s
Independent fixed offsets caused the timeline, map dock, tool panel, and zoom controls to overlap. Derive them from one layout function and scope collapse to the expanded timeline state.
2026-08-19 18:55:43 +08:00
jiang abef9ffabf fix: bypass blocked Keycloak session iframe
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m31s
2026-08-19 18:12:21 +08:00
jiang db696eb4f1 test: align collapsed agent camera padding
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-19 18:00:40 +08:00
jiang 229cb0def2 fix: animate analysis workspace transitions 2026-08-19 17:54:37 +08:00
jiang 1fb950806a fix: unify analysis workspace controls 2026-08-19 17:43:14 +08:00
jiang 073832034a fix: restyle analysis return control 2026-08-19 17:33:19 +08:00
jiang 57c494e8bd refactor: simplify evidence workspace presentation 2026-08-19 17:31:21 +08:00
jiang 4a589d7a43 fix: refine evidence detail presentation 2026-08-19 17:28:21 +08:00
jiang d39e58196f fix: tighten timeline lane spacing 2026-08-19 17:21:41 +08:00
jiang b93999646c fix: simplify timeline legend controls 2026-08-19 17:15:48 +08:00
jiang b91be09f5c fix: distinguish timeline attention events 2026-08-19 17:12:13 +08:00
jiang 602b39d60a feat: add interactive timeline legend 2026-08-19 17:07:22 +08:00
jiang 5e7101bfee fix: default Agent panel to collapsed 2026-08-19 17:00:43 +08:00
15 changed files with 809 additions and 305 deletions
@@ -69,7 +69,7 @@ export function useWorkbenchAgent({
const frontendActionEnabledRef = useRef(false); const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction); const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>()); const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(true); const [panelOpen, setPanelOpen] = useState(false);
const [panelCollapsing, setPanelCollapsing] = useState(false); const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false); const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
@@ -5,7 +5,8 @@ import {
getAgentPanelMaxWidth, getAgentPanelMaxWidth,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchCameraPadding, getWorkbenchCameraPadding,
getWorkbenchViewportLayout getWorkbenchViewportLayout,
resolveMobileWorkbenchOverlayLayout
} from "./workbench-layout"; } from "./workbench-layout";
describe("workbench basemap surface tone", () => { describe("workbench basemap surface tone", () => {
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
}); });
describe("workbench floating panels", () => { describe("workbench floating panels", () => {
it("derives mobile dock and map control offsets from one overlay stack", () => {
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
timelineBottom: 12,
dockBottom: 68,
mapControlBottom: 120
});
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
timelineBottom: 12,
dockBottom: 12,
mapControlBottom: 64
});
});
it("uses bounded desktop and wide panel widths", () => { it("uses bounded desktop and wide panel widths", () => {
expect(getWorkbenchViewportLayout(1440)).toMatchObject({ expect(getWorkbenchViewportLayout(1440)).toMatchObject({
agentWidth: 500, agentWidth: 500,
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
mobileSheet: { mobileSheet: {
halfViewportRatio: 0.52, halfViewportRatio: 0.52,
fullTopGap: 68 fullTopGap: 68
},
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
timelineSummaryHeight: 48
} }
} as const; } as const;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
return {
timelineBottom: edgeInset,
dockBottom,
mapControlBottom: dockBottom + dockHeight + gap
};
}
export function clampAgentPanelWidth( export function clampAgentPanelWidth(
width: number, width: number,
viewportWidth: number, viewportWidth: number,
+36 -12
View File
@@ -78,7 +78,8 @@ import {
import { import {
WORKBENCH_LAYOUT, WORKBENCH_LAYOUT,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout"; } from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera"; import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view"; import { exportMapViewImage } from "./map/export-view";
@@ -220,9 +221,10 @@ export function MapWorkbenchPage({
timelineMode, timelineMode,
timelineVisible timelineVisible
); );
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
const timelineBottom = isLargeScreen const timelineBottom = isLargeScreen
? timelineScaleLayout.timelineBottom ? timelineScaleLayout.timelineBottom
: 12; : mobileOverlayLayout.timelineBottom;
useEffect(() => { useEffect(() => {
setTimelineMode((current) => { setTimelineMode((current) => {
@@ -987,18 +989,30 @@ export function MapWorkbenchPage({
onReturnNow={handleReturnOperationalNow} onReturnNow={handleReturnOperationalNow}
/> />
</div> </div>
<div
role="region"
aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
>
<button <button
type="button" type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport} onClick={openTimelineForViewport}
className="acrylic-control flex h-12 w-full items-center gap-2 rounded-2xl border px-3 text-left text-xs text-slate-700 lg:hidden" className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
> >
<Clock3 size={15} className="text-blue-600" aria-hidden="true" /> <Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="font-semibold"></span> <span className="shrink-0 font-semibold"></span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500"> {operationalSummary.planned}</span> <span className="text-slate-500"> {operationalSummary.planned}</span>
<span className="text-slate-500"> {operationalSummary.active}</span> <span className="text-slate-500"> {operationalSummary.active}</span>
<span className="text-amber-700"> {operationalSummary.exceptions}</span> <span className="text-amber-700"> {operationalSummary.attention}</span>
<span className="ml-auto text-slate-500"></span> <span className="text-rose-700"> {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
</span>
</button> </button>
</div>
</> : null </> : null
)} )}
mapContent={( mapContent={(
@@ -1018,10 +1032,13 @@ export function MapWorkbenchPage({
<ToolbarPanel {...toolbarPanelProps} /> <ToolbarPanel {...toolbarPanelProps} />
</div> </div>
<div <div
className="absolute bottom-0 right-0 flex flex-col items-end gap-2" className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{ bottom: timelineScaleLayout.scaleBottom }} style={{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties}
> >
<div className="flex items-end gap-2 pr-2"> <div className="flex items-end gap-2 lg:pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} /> <MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div> </div>
<MapScaleLine <MapScaleLine
@@ -1076,7 +1093,10 @@ export function MapWorkbenchPage({
) : null} ) : null}
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? ( {mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden"> <div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
>
<ToolbarPanel <ToolbarPanel
{...toolbarPanelProps} {...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto" panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
@@ -1107,7 +1127,10 @@ export function MapWorkbenchPage({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
<div className={`absolute z-30 lg:hidden ${timelineVisible ? "bottom-[4.25rem]" : "bottom-3"} ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}> <div
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
style={{ bottom: mobileOverlayLayout.dockBottom }}
>
{mobileSheet === null ? ( {mobileSheet === null ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
@@ -1166,6 +1189,7 @@ export function MapWorkbenchPage({
events={operationalEvents} events={operationalEvents}
mode="expanded" mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId} selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={closeMobileSheet}
onSelectEvent={handleSelectOperationalEvent} onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection} onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow} onReturnNow={handleReturnOperationalNow}
+2 -2
View File
@@ -18,7 +18,7 @@ describe("workbench camera padding", () => {
{ {
agentOpen: false, agentOpen: false,
conditionOpen: false, conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 96 } expected: { top: 72, right: 72, bottom: 32, left: 24 }
}, },
{ {
agentOpen: true, agentOpen: true,
@@ -28,7 +28,7 @@ describe("workbench camera padding", () => {
{ {
agentOpen: false, agentOpen: false,
conditionOpen: true, conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 96 } expected: { top: 72, right: 504, bottom: 32, left: 24 }
}, },
{ {
agentOpen: true, agentOpen: true,
@@ -4,15 +4,33 @@ import {
canCenterTimelineBesideMapScale, canCenterTimelineBesideMapScale,
clusterOperationalEvents, clusterOperationalEvents,
createOperationalEvents, createOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventCategory,
OPERATIONAL_TIMELINE_LAYOUT, OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout resolveTimelineScaleLayout
} from "./operational-timeline-model"; } from "./operational-timeline-model";
import type { OperationalEvent } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = { const workOrder: ScheduledConditionItem = {
id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z", id: "work-1",
title: "阀门调整", summary: "调整阀门", status: "running", riskLevel: "attention", updatedAt: 1, kind: "work_order",
durationMinutes: 40, source: "调度方案", location: "边界阀门 BV-07", dispatcher: "调度", code: "WO-1",
assignee: "班组", priority: "urgent", replyWindowMinutes: 20, stages: [], replyRequirements: [] scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整",
summary: "调整阀门",
status: "running",
riskLevel: "attention",
updatedAt: 1,
durationMinutes: 40,
source: "调度方案",
location: "边界阀门 BV-07",
dispatcher: "调度",
assignee: "班组",
priority: "urgent",
replyWindowMinutes: 20,
stages: [],
replyRequirements: []
}; };
describe("operational timeline model", () => { describe("operational timeline model", () => {
@@ -26,13 +44,57 @@ describe("operational timeline model", () => {
expect(events[0].correlationId).toBe(events[1].correlationId); expect(events[0].correlationId).toBe(events[1].correlationId);
}); });
it("filters event lanes from the interactive legend", () => {
const events = createOperationalEvents([workOrder]);
const filtered = filterOperationalEvents(events, {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
actual: false
});
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
});
it("separates attention events from failed exceptions", () => {
const [planEvent] = createOperationalEvents([workOrder]);
const attentionEvent = {
...planEvent,
id: "attention-1",
lane: "exception",
status: "delayed"
} satisfies OperationalEvent;
const exceptionEvent = {
...attentionEvent,
id: "exception-1",
status: "failed"
} satisfies OperationalEvent;
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
attention: false
})).toEqual([exceptionEvent]);
});
it("only promotes abnormal condition runs into exception events", () => { it("only promotes abnormal condition runs into exception events", () => {
const normal = { const normal = {
id: "condition-normal", kind: "condition", taskId: "scada-diagnosis", sessionId: "s-1", id: "condition-normal",
scheduledAt: "2026-08-19T10:00:00.000Z", title: "诊断", summary: "正常", status: "completed", kind: "condition",
riskLevel: "normal", updatedAt: 1 taskId: "scada-diagnosis",
sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "诊断",
summary: "正常",
status: "completed",
riskLevel: "normal",
updatedAt: 1
} satisfies ScheduledConditionItem;
const abnormal = {
...normal,
id: "condition-error",
status: "error",
riskLevel: "critical"
} satisfies ScheduledConditionItem; } satisfies ScheduledConditionItem;
const abnormal = { ...normal, id: "condition-error", status: "error", riskLevel: "critical" } satisfies ScheduledConditionItem;
expect(createOperationalEvents([normal])).toHaveLength(0); expect(createOperationalEvents([normal])).toHaveLength(0);
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception"); expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
@@ -1,10 +1,16 @@
import type { FeatureTarget } from "../map/workbench-map-controller"; import type { FeatureTarget } from "../map/workbench-map-controller";
import type { ScheduledConditionItem, ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types"; import type {
ScheduledConditionItem,
ScheduledConditionRecord,
ScheduledWorkOrderItem
} from "../types";
export type OperationalTimelineMode = "compact" | "expanded" | "summary"; export type OperationalTimelineMode = "compact" | "expanded" | "summary";
export type OperationalEventLane = "plan" | "actual" | "exception"; export type OperationalEventLane = "plan" | "actual" | "exception";
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled"; export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
export type OperationalEventImportance = "normal" | "important" | "critical"; export type OperationalEventImportance = "normal" | "important" | "critical";
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
export type OperationalEvent = { export type OperationalEvent = {
id: string; id: string;
@@ -42,6 +48,13 @@ export const OPERATIONAL_TIMELINE_LAYOUT = {
overlayReservedHeight: 0 overlayReservedHeight: 0
} as const; } as const;
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
plan: true,
actual: true,
attention: true,
exception: true
};
export type TimelineScaleLayout = { export type TimelineScaleLayout = {
scaleBottom: number; scaleBottom: number;
timelineBottom: number; timelineBottom: number;
@@ -54,11 +67,10 @@ export function canCenterTimelineBesideMapScale(
) { ) {
if (!visible) return true; if (!visible) return true;
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode]; const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
return businessWorkspaceWidth >= return (
businessWorkspaceWidth >=
layout.maxWidth + layout.maxWidth +
2 * ( 2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
OPERATIONAL_TIMELINE_LAYOUT.floatingGap
); );
} }
@@ -83,9 +95,11 @@ export function resolveTimelineScaleLayout(
} }
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] { export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items.flatMap((item) => return items
.flatMap((item) =>
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item) item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime)); )
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
} }
export function clusterOperationalEvents( export function clusterOperationalEvents(
@@ -116,17 +130,33 @@ export function getOperationalEventSummary(events: OperationalEvent[]) {
return { return {
planned: events.filter((event) => event.lane === "plan").length, planned: events.filter((event) => event.lane === "plan").length,
active: events.filter((event) => event.lane === "actual").length, active: events.filter((event) => event.lane === "actual").length,
exceptions: events.filter((event) => event.lane === "exception").length, attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
critical: events.filter((event) => event.importance === "critical").length critical: events.filter((event) => event.importance === "critical").length
}; };
} }
export function getOperationalEventCategory(
event: OperationalEvent
): OperationalEventCategory {
if (event.lane === "plan" || event.lane === "actual") return event.lane;
return event.status === "failed" ? "exception" : "attention";
}
export function filterOperationalEvents(
events: OperationalEvent[],
visibility: OperationalEventVisibility
) {
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] { function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.status !== "warning" && item.status !== "error") { if (item.status !== "warning" && item.status !== "error") {
return []; return [];
} }
return [{ return [
{
id: `exception-${item.id}`, id: `exception-${item.id}`,
title: item.title, title: item.title,
description: item.report?.conclusion ?? item.summary, description: item.report?.conclusion ?? item.summary,
@@ -141,7 +171,8 @@ function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent
conditionId: item.id, conditionId: item.id,
sourceLabel: "工况诊断", sourceLabel: "工况诊断",
mapTargets: getConditionMapTargets(item) mapTargets: getConditionMapTargets(item)
}]; }
];
} }
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] { function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
@@ -9,15 +9,20 @@ import {
TriangleAlert TriangleAlert
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { import {
clusterOperationalEvents, clusterOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventSummary, getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT, OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent, type OperationalEvent,
type OperationalEventCategory,
type OperationalEventCluster, type OperationalEventCluster,
type OperationalEventLane, type OperationalEventLane,
type OperationalEventVisibility,
type OperationalTimelineMode type OperationalTimelineMode
} from "./operational-timeline-model"; } from "./operational-timeline-model";
@@ -40,6 +45,13 @@ const LANES: Array<{ id: OperationalEventLane; label: string }> = [
{ id: "exception", label: "异常" } { id: "exception", label: "异常" }
]; ];
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "attention", label: "关注" },
{ id: "exception", label: "异常" }
];
export function OperationalTimeline({ export function OperationalTimeline({
date, date,
cursorTime, cursorTime,
@@ -53,8 +65,12 @@ export function OperationalTimeline({
onReturnNow onReturnNow
}: OperationalTimelineProps) { }: OperationalTimelineProps) {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
}));
const summary = getOperationalEventSummary(events); const summary = getOperationalEventSummary(events);
const clusters = clusterOperationalEvents(events, mobile ? 60 : 10); const visibleEvents = filterOperationalEvents(events, eventVisibility);
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null; const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
const cursorPosition = getDayPosition(cursorTime); const cursorPosition = getDayPosition(cursorTime);
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height; const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
@@ -86,6 +102,10 @@ export function OperationalTimeline({
) : ( ) : (
<span className="min-w-0 truncate text-xs text-slate-500"></span> <span className="min-w-0 truncate text-xs text-slate-500"></span>
)} )}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} /> <TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</motion.section> </motion.section>
); );
@@ -114,12 +134,19 @@ export function OperationalTimeline({
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" /> <Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className={cn("text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500", mode === "compact" && !mobile && "hidden")}></p> <p
className={cn(
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
((mode === "compact" && !mobile) || mobile) && "hidden"
)}
>
</p>
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p> <p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
</div> </div>
</div> </div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" /> <span className="hidden h-5 w-px bg-slate-300 sm:block" />
<TimelineSummary summary={summary} /> {!mobile ? <TimelineSummary summary={summary} /> : null}
{selectedEvent && !mobile ? ( {selectedEvent && !mobile ? (
<button <button
type="button" type="button"
@@ -130,6 +157,10 @@ export function OperationalTimeline({
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title} {formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button> </button>
) : null} ) : null}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} /> <TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div> </div>
@@ -137,16 +168,18 @@ export function OperationalTimeline({
{mode === "expanded" || mobile ? ( {mode === "expanded" || mobile ? (
<motion.div <motion.div
key="expanded-timeline" key="expanded-timeline"
className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]" className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }} initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }} exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }} transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
> >
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500"> <div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
<span /> <span />
{LANES.map((lane) => ( {LANES.map((lane) => (
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span> <span key={lane.id} className="flex items-center justify-center font-semibold">
{lane.label}
</span>
))} ))}
</div> </div>
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2"> <div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
@@ -262,7 +295,8 @@ function TimelineMarker({
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]", "rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
event.lane === "actual" && event.lane === "actual" &&
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]", "rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
exception && event.status !== "failed" && exception &&
event.status !== "failed" &&
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]", "rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
event.status === "failed" && event.status === "failed" &&
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]", "rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
@@ -279,23 +313,58 @@ function TimelineMarker({
) : null} ) : null}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-72 bg-white px-3 py-2 text-slate-900 shadow-xl"> <TooltipContent
<div className="flex items-center gap-2"> side="top"
<span className={cn( sideOffset={10}
"rounded-md px-1.5 py-0.5 text-[10px] font-semibold", className={cn(
event.lane === "plan" && "bg-blue-50 text-blue-700", "acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
event.lane === "actual" && "bg-emerald-50 text-emerald-700", )}
event.lane === "exception" && event.status !== "failed" && "bg-amber-50 text-amber-800", >
event.status === "failed" && "bg-rose-50 text-rose-700" <div className="surface-reading min-w-0 rounded-[13px] border p-3">
)}> <div className="flex items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
event.lane === "exception" &&
event.status !== "failed" &&
"border-amber-200 bg-amber-50 text-amber-800",
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
)}
>
<OperationalEventGlyph event={event} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"text-[10px] font-bold tracking-[0.08em]",
event.lane === "plan" && "text-blue-700",
event.lane === "actual" && "text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
event.status === "failed" && "text-rose-700"
)}
>
{operationalEventNatureLabel(event)} {operationalEventNatureLabel(event)}
</span> </span>
<span className="text-[10px] text-slate-500">{event.sourceLabel}</span> <time className="text-[11px] font-semibold tabular-nums text-slate-700">
{formatClock(event.cursorTime)}
</time>
</div>
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
</div>
</div>
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
{event.title}
</p>
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
{event.description}
</p>
{cluster.events.length > 1 ? (
<p className="mt-2 text-[10px] text-slate-500"> {cluster.events.length} </p>
) : null}
</div> </div>
<p className="mt-1.5 font-semibold">{formatClock(event.cursorTime)} · {event.title}</p>
<p className="mt-1 leading-5 text-slate-600">{event.description}</p>
{cluster.events.length > 1 ? <p className="mt-1 text-slate-500"> {cluster.events.length} </p> : null}
{event.conditionId ? <p className="mt-1 font-medium text-blue-700"></p> : null}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
@@ -315,8 +384,8 @@ function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
} }
function operationalEventNatureLabel(event: OperationalEvent) { function operationalEventNatureLabel(event: OperationalEvent) {
if (event.status === "failed") return "关键异常"; if (event.status === "failed") return "异常";
if (event.lane === "exception") return "运行偏差"; if (event.lane === "exception") return "关注";
if (event.lane === "actual") return "实际执行"; if (event.lane === "actual") return "实际执行";
return "计划任务"; return "计划任务";
} }
@@ -324,9 +393,18 @@ function operationalEventNatureLabel(event: OperationalEvent) {
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) { function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
return ( return (
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3"> <div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
<span> <strong className="text-blue-700">{summary.planned}</strong></span> <span>
<span> <strong className="text-emerald-700">{summary.active}</strong></span> <strong className="text-blue-700">{summary.planned}</strong>
<span> <strong className="text-amber-700">{summary.exceptions}</strong></span> </span>
<span>
<strong className="text-emerald-700">{summary.active}</strong>
</span>
<span>
<strong className="text-amber-700">{summary.attention}</strong>
</span>
<span>
<strong className="text-rose-700">{summary.exceptions}</strong>
</span>
</div> </div>
); );
} }
@@ -341,7 +419,7 @@ function TimelineActions({
onReturnNow: () => void; onReturnNow: () => void;
}) { }) {
return ( return (
<div className="surface-control ml-auto flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]"> <div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<button <button
type="button" type="button"
title="返回现在" title="返回现在"
@@ -375,6 +453,60 @@ function TimelineActions({
); );
} }
function TimelineLegend({
visibility,
onVisibilityChange
}: {
visibility: OperationalEventVisibility;
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
}) {
return (
<div
aria-label="时间轴图例"
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
>
{LEGEND_ITEMS.map((item) => {
const visible = visibility[item.id];
return (
<button
key={item.id}
type="button"
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-pressed={visible}
onClick={() =>
onVisibilityChange({
...visibility,
[item.id]: !visible
})
}
className={cn(
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
visible ? "opacity-100" : "grayscale opacity-25"
)}
>
<LegendSwatch category={item.id} />
</button>
);
})}
</div>
);
}
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
return (
<span
className={cn(
"block h-3.5 w-3.5 shrink-0 border",
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
)}
/>
);
}
function TimeTicks() { function TimeTicks() {
return ( return (
<div className="relative text-[9px] text-slate-600"> <div className="relative text-[9px] text-slate-600">
@@ -414,7 +546,9 @@ function CompactTimeTicks() {
} }
function pickClusterEvent(events: OperationalEvent[]) { function pickClusterEvent(events: OperationalEvent[]) {
return [...events].sort((left, right) => importanceRank(right.importance) - importanceRank(left.importance))[0]; return [...events].sort(
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
)[0];
} }
function importanceRank(value: OperationalEvent["importance"]) { function importanceRank(value: OperationalEvent["importance"]) {
@@ -428,7 +562,11 @@ function getDayPosition(value: string) {
} }
function formatClock(value: string) { function formatClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
} }
function formatDate(value: string) { function formatDate(value: string) {
@@ -7,52 +7,46 @@ import {
Clock3, Clock3,
Database, Database,
FileSearch, FileSearch,
Focus,
Map as MapIcon,
Minimize2, Minimize2,
PanelRightClose,
PanelRightOpen,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
Trash2, Trash2,
X X
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { showMapNotice } from "@/features/map/core"; import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type { import type {
AnalysisArtifact, AnalysisArtifact,
AnalysisArtifactAction, AnalysisArtifactAction,
AnalysisBlock, AnalysisBlock,
AnalysisMetric, AnalysisMetric
ArtifactRequestedView,
WorkbenchSurfaceMode
} from "./workspace-model"; } from "./workspace-model";
type AnalysisArtifactWorkspaceProps = { type AnalysisArtifactWorkspaceProps = {
artifact: AnalysisArtifact; artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void; onCollapse: () => void;
onDestroy: () => void; onDestroy: () => void;
}; };
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
export function AnalysisArtifactWorkspace({ export function AnalysisArtifactWorkspace({
artifact, artifact,
surfaceMode,
mapSplitAvailable,
onSetView,
onCollapse, onCollapse,
onDestroy onDestroy
}: AnalysisArtifactWorkspaceProps) { }: AnalysisArtifactWorkspaceProps) {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null); const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid"); const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid"); const evidenceBlocks = artifact.blocks.filter(
const selectedBlock = useMemo( (block): block is EvidenceBlock => block.kind !== "metric-grid"
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
[artifact.blocks, selectedBlockId]
); );
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
useEffect(() => { useEffect(() => {
setSelectedBlockId(null); setSelectedBlockId(null);
@@ -116,11 +110,6 @@ export function AnalysisArtifactWorkspace({
<ArtifactActionBar <ArtifactActionBar
artifact={artifact} artifact={artifact}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
detailOpen={Boolean(selectedBlock)}
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
onSetView={onSetView}
onCollapse={onCollapse} onCollapse={onCollapse}
onDestroy={onDestroy} onDestroy={onDestroy}
/> />
@@ -176,7 +165,7 @@ function AnalysisBlockView({
selected, selected,
onSelect onSelect
}: { }: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>; block: EvidenceBlock;
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
@@ -198,11 +187,11 @@ function AnalysisBlockView({
> >
<button <button
type="button" type="button"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left active:scale-[0.99]" className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60"
aria-label={`查看${block.title}详情`} aria-label={`查看${block.title}详情`}
onClick={onSelect} onClick={onSelect}
> >
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700"> <span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<FileSearch size={16} aria-hidden="true" /> <FileSearch size={16} aria-hidden="true" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -218,7 +207,7 @@ function AnalysisBlockView({
); );
} }
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) { function AnalysisBlockBody({ block }: { block: EvidenceBlock }) {
if (block.kind === "chart") { if (block.kind === "chart") {
return ( return (
<div className="h-[258px] min-h-0"> <div className="h-[258px] min-h-0">
@@ -280,14 +269,28 @@ function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "m
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) { function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
return ( return (
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制"> <section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
<div className="surface-well rounded-xl border px-4 py-3.5"> <div className="flex min-w-0 items-start gap-3 px-4 py-3">
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" /></h3> <h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul> <Database size={14} aria-hidden="true" />
</h3>
<ul className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 pt-1 text-xs text-slate-600">
{artifact.sources.map((source) => (
<li key={source} className="after:ml-2 after:text-slate-300 after:content-['/'] last:after:hidden">{source}</li>
))}
</ul>
</div> </div>
<div className="material-tone-warning rounded-xl border px-4 py-3.5"> <div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" /></h3> <h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}> {item}</li>)}</ul> <AlertTriangle size={14} aria-hidden="true" />
</h3>
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
{artifact.limitations.map((item) => (
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
))}
</ul>
</div> </div>
</section> </section>
); );
@@ -299,79 +302,140 @@ function ArtifactDetailDrawer({
onClose onClose
}: { }: {
artifact: AnalysisArtifact; artifact: AnalysisArtifact;
block: AnalysisBlock; block: EvidenceBlock;
onClose: () => void; onClose: () => void;
}) { }) {
const blockFacts = getEvidenceBlockFacts(block);
return ( return (
<aside aria-label="证据详情" className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l shadow-[-12px_0_36px_rgba(15,23,42,0.18)]"> <aside
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100"> aria-label="证据详情"
<div> className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p> >
<h3 className="mt-1 text-sm font-semibold">{block.title}</h3> <div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<FileSearch size={17} className="mt-0.5 shrink-0 text-blue-700" aria-hidden="true" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
<span className="uppercase tracking-[0.12em]"></span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
</div> </div>
<button type="button" aria-label="关闭证据详情" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-md text-slate-300 hover:bg-white/10 hover:text-white active:scale-95"> <h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
<X size={18} aria-hidden="true" /> </div>
</div>
<button
type="button"
aria-label="关闭证据详情"
onClick={onClose}
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
>
<X size={15} aria-hidden="true" />
</button> </button>
</div> </div>
<div className="space-y-5 p-5"> <div className="space-y-5 px-5 py-5">
<div> <section aria-labelledby="evidence-purpose-heading">
<p className="text-xs font-semibold text-slate-500"></p> <p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p> <p className="mt-2 text-sm leading-6 text-slate-700">
{evidenceBlockDescription(block)}
</p>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-data-heading">
<div className="flex items-center justify-between gap-3">
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500"></h4>
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
</div> </div>
<div> <dl className="mt-2 divide-y divide-slate-200">
<p className="text-xs font-semibold text-slate-500"></p> {blockFacts.map((fact) => (
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p> <div key={fact.label} className="flex items-center justify-between gap-4 py-2 text-xs">
<dt className="text-slate-500">{fact.label}</dt>
<dd className="font-medium text-slate-800 tabular-nums">{fact.value}</dd>
</div> </div>
<div className="bg-slate-100 p-4"> ))}
<p className="text-xs font-semibold text-slate-700"></p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">R{artifact.revision}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.confidence}</dd>
</dl> </dl>
</div> </section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500"></h4>
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
</dl>
</section>
</div> </div>
</aside> </aside>
); );
} }
function evidenceBlockDescription(block: EvidenceBlock) {
return "description" in block
? block.description
: block.paragraphs[0] ?? "该模块汇总了当前成果中的关键判断依据。";
}
function evidenceBlockKindLabel(block: EvidenceBlock) {
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
if (block.kind === "process-flow") return "流程证据";
if (block.kind === "data-table") return "明细数据";
return "分析说明";
}
function getEvidenceBlockFacts(block: EvidenceBlock): Array<{ label: string; value: string }> {
if (block.kind === "chart") {
return [
{ label: "数据序列", value: `${block.series.length}` },
{ label: "时间 / 类别点", value: `${block.categories.length}` },
{ label: "计量单位", value: block.unit }
];
}
if (block.kind === "data-table") {
return [
{ label: "证据记录", value: `${block.rows.length}` },
{ label: "数据字段", value: `${block.columns.length}` }
];
}
if (block.kind === "process-flow") {
return [
{ label: "流程步骤", value: `${block.nodes.length}` },
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length}` },
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length}` }
];
}
return [{ label: "分析段落", value: `${block.paragraphs.length}` }];
}
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
const formatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
return `${formatter.format(new Date(range.start))}${formatter.format(new Date(range.end))}`;
}
function ArtifactActionBar({ function ArtifactActionBar({
artifact, artifact,
surfaceMode,
mapSplitAvailable,
detailOpen,
onToggleDetail,
onSetView,
onCollapse, onCollapse,
onDestroy onDestroy
}: { }: {
artifact: AnalysisArtifact; artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
detailOpen: boolean;
onToggleDetail: () => void;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void; onCollapse: () => void;
onDestroy: () => void; onDestroy: () => void;
}) { }) {
return ( return (
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3"> <footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
<div className="flex min-w-0 items-center gap-1"> <span />
{artifact.mapRelation !== "none" ? (
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{surfaceMode === "map_split" ? <Focus size={15} aria-hidden="true" /> : <MapIcon size={15} aria-hidden="true" />}
{surfaceMode === "map_split" ? "专注分析" : "显示地图"}
</button>
) : null}
<button type="button" aria-pressed={detailOpen} onClick={onToggleDetail} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{detailOpen ? <PanelRightClose size={15} aria-hidden="true" /> : <PanelRightOpen size={15} aria-hidden="true" />}
</button>
</div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)} {artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className="grid h-10 w-10 place-items-center rounded-md text-slate-600 hover:bg-slate-100 active:scale-95"><Minimize2 size={16} aria-hidden="true" /></button> <button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className="grid h-10 w-10 place-items-center rounded-md text-slate-500 hover:bg-rose-50 hover:text-rose-700 active:scale-95"><Trash2 size={16} aria-hidden="true" /></button> <button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className={ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME}><Trash2 size={15} aria-hidden="true" /></button>
</div> </div>
</footer> </footer>
); );
@@ -382,9 +446,9 @@ function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactA
<button <button
type="button" type="button"
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}${action.description}` })} onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}${action.description}` })}
className="hidden h-10 items-center gap-2 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.24)] hover:bg-blue-700 active:scale-95 sm:inline-flex" className={cn("hidden sm:inline-flex", ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME)}
> >
<ShieldCheck size={15} aria-hidden="true" /> <ShieldCheck size={14} aria-hidden="true" />
{action.label} {action.label}
</button> </button>
); );
@@ -1,5 +1,9 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react"; import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model"; import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
export function ArtifactPeekBar({ export function ArtifactPeekBar({
@@ -17,7 +21,7 @@ export function ArtifactPeekBar({
<aside <aside
aria-label="Agent 成果预览" aria-label="Agent 成果预览"
className={cn( className={cn(
"acrylic-panel pointer-events-auto absolute top-28 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900", "acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2", "left-1/2 -translate-x-1/2",
surfaceMode === "map_split" && "2xl:left-[25%]" surfaceMode === "map_split" && "2xl:left-[25%]"
)} )}
@@ -36,9 +40,9 @@ export function ArtifactPeekBar({
<button <button
type="button" type="button"
onClick={onOpen} onClick={onOpen}
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-md bg-blue-500 px-3 text-xs font-semibold text-white hover:bg-blue-400 active:scale-95" className={ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME}
> >
<ChevronUp size={15} aria-hidden="true" /> <ChevronUp size={14} aria-hidden="true" />
</button> </button>
<button <button
@@ -46,9 +50,9 @@ export function ArtifactPeekBar({
aria-label="销毁预览成果" aria-label="销毁预览成果"
title="销毁预览成果" title="销毁预览成果"
onClick={onDestroy} onClick={onDestroy}
className="surface-control grid h-10 w-10 shrink-0 place-items-center rounded-xl border text-slate-500 hover:text-rose-700 active:scale-95" className={cn("surface-control border", ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME)}
> >
<Trash2 size={16} aria-hidden="true" /> <Trash2 size={15} aria-hidden="true" />
</button> </button>
</aside> </aside>
); );
@@ -0,0 +1,14 @@
export const ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME =
"surface-control inline-flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]";
export const ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME =
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME =
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 text-[11px] font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.2)] transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2";
export const ARTIFACT_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 active:bg-slate-200/70 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 active:bg-rose-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-rose-500/60";
@@ -1,10 +1,14 @@
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react"; import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model"; import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
import { AnalysisArtifactWorkspace } from "./analysis-document-view"; import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar"; import { ArtifactPeekBar } from "./artifact-peek-bar";
import {
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
} from "./artifact-view-control-styles";
import type { import type {
AnalysisArtifact, AnalysisArtifact,
ArtifactRequestedView, ArtifactRequestedView,
@@ -46,10 +50,28 @@ export function WorkbenchMainFrame({
}: WorkbenchMainFrameProps) { }: WorkbenchMainFrameProps) {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus"; const showMap = surfaceMode !== "focus";
const showArtifact = activeArtifact && surfaceMode !== "map_only"; const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(
activeArtifact &&
activeArtifact.mapRelation !== "none"
);
const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split";
const viewControlLabel = mapOnly
? "返回分析成果"
: mapSplit
? "专注分析"
: "显示地图";
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
const timelineTransition = prefersReducedMotion const timelineTransition = prefersReducedMotion
? { duration: 0 } ? { duration: 0 }
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const }; : { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
const viewControlTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
const artifactTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
return ( return (
<div <div
@@ -77,39 +99,41 @@ export function WorkbenchMainFrame({
> >
{mapContent} {mapContent}
{mapOverlay} {mapOverlay}
{activeArtifact && surfaceMode === "map_only" ? (
<button
type="button"
onClick={() => onSetArtifactView("focus")}
className="pointer-events-auto absolute left-3 top-3 z-20 inline-flex h-10 items-center gap-2 rounded-md bg-slate-950 px-3 text-xs font-semibold text-white shadow-[0_8px_24px_rgba(15,23,42,0.22)] hover:bg-slate-800 active:scale-95 lg:left-4 lg:top-4"
>
<ArrowLeft size={15} aria-hidden="true" />
<FileChartColumnIncreasing size={15} aria-hidden="true" />
</button>
) : null}
</section> </section>
<AnimatePresence initial={false} mode="popLayout">
{activeArtifact ? ( {activeArtifact ? (
<section <motion.section
key={activeArtifact.id}
id="artifact-workspace" id="artifact-workspace"
aria-label={`${activeArtifact.title}工作区`} aria-label={`${activeArtifact.title}工作区`}
aria-hidden={!showArtifact} aria-hidden={!showArtifact}
className={cn( className={cn(
"relative z-30 min-h-0 min-w-0 overflow-hidden", "z-30 min-h-0 min-w-0 overflow-hidden",
!showArtifact && "hidden" showArtifact ? "relative" : "pointer-events-none absolute inset-0"
)} )}
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
animate={showArtifact
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
: {
opacity: 0,
y: 8,
scale: 0.994,
transitionEnd: { visibility: "hidden" }
}}
exit={prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, y: 10, scale: 0.992 }}
transition={artifactTransition}
> >
<AnalysisArtifactWorkspace <AnalysisArtifactWorkspace
artifact={activeArtifact} artifact={activeArtifact}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
onSetView={onSetArtifactView}
onCollapse={onCollapseArtifact} onCollapse={onCollapseArtifact}
onDestroy={onDestroyArtifact} onDestroy={onDestroyArtifact}
/> />
</section> </motion.section>
) : null} ) : null}
</AnimatePresence>
{pendingArtifact ? ( {pendingArtifact ? (
<ArtifactPeekBar <ArtifactPeekBar
@@ -119,6 +143,54 @@ export function WorkbenchMainFrame({
onDestroy={onDestroyPendingArtifact} onDestroy={onDestroyPendingArtifact}
/> />
) : null} ) : null}
<AnimatePresence initial={false}>
{showArtifactViewControl ? (
<motion.div
className={cn(
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
)}
initial={prefersReducedMotion
? false
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }}
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition}
>
<button
type="button"
aria-label={viewControlLabel}
onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={surfaceMode}
className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
transition={viewControlTransition}
>
{mapOnly ? (
<ArrowLeft size={13} aria-hidden="true" />
) : mapSplit ? (
<Focus size={13} aria-hidden="true" />
) : (
<MapIcon size={13} aria-hidden="true" />
)}
{viewControlLabel}
</motion.span>
</AnimatePresence>
</button>
</motion.div>
) : null}
</AnimatePresence>
</div> </div>
<motion.div <motion.div
+2 -1
View File
@@ -40,7 +40,8 @@ describe("Keycloak authentication", () => {
expect(client.init).toHaveBeenCalledWith({ expect(client.init).toHaveBeenCalledWith({
onLoad: "login-required", onLoad: "login-required",
flow: "standard", flow: "standard",
pkceMethod: "S256" pkceMethod: "S256",
checkLoginIframe: false
}); });
expect(authentication.user).toEqual({ expect(authentication.user).toEqual({
name: "张调度", name: "张调度",
+2 -1
View File
@@ -59,7 +59,8 @@ export async function initializeAuthentication(
const authenticated = await keycloak.init({ const authenticated = await keycloak.init({
onLoad: "login-required", onLoad: "login-required",
flow: "standard", flow: "standard",
pkceMethod: "S256" pkceMethod: "S256",
checkLoginIframe: false
}); });
if (!authenticated) { if (!authenticated) {
+59
View File
@@ -113,6 +113,65 @@ test("mobile launchers and map dock keep non-overlapping touch targets", async (
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x); expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
}); });
test("mobile timeline only offers collapse after opening its detail sheet", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const timelineSummary = page.getByRole("region", { name: "移动端运行时间轴摘要" });
await expect(timelineSummary).toBeVisible();
await expect(timelineSummary.getByRole("button", { name: "隐藏运行时间轴" })).toHaveCount(0);
await timelineSummary.getByRole("button", { name: "打开完整运行时间轴" }).click();
const timelineSheet = page.getByRole("region", { name: "运行时间轴抽屉" });
await expect(timelineSheet).toBeVisible();
await timelineSheet.getByRole("button", { name: "收起时间轴" }).click();
await expect(timelineSheet).toBeHidden();
await expect(timelineSummary).toBeVisible();
});
test("mobile map tool panel stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
const toolPanel = page.getByRole("region", { name: "更多" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(toolPanel).toBeVisible();
await expect
.poll(async () => {
const [panelBox, dockBox] = await Promise.all([
toolPanel.boundingBox(),
mapDock.boundingBox()
]);
if (!panelBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(panelBox.y + panelBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
});
test("mobile map zoom stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const mapZoom = page.getByRole("group", { name: "地图缩放" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(mapZoom).toBeVisible();
await expect
.poll(async () => {
const [zoomBox, dockBox] = await Promise.all([
mapZoom.boundingBox(),
mapDock.boundingBox()
]);
if (!zoomBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(zoomBox.y + zoomBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
const zoomBox = await mapZoom.boundingBox();
expect(Math.round(375 - zoomBox!.x - zoomBox!.width)).toBe(12);
});
test("Agent content buttons do not gain a border while pressed", async ({ page }) => { test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });