Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28287a1447 | ||
|
|
abef9ffabf | ||
|
|
db696eb4f1 | ||
|
|
229cb0def2 | ||
|
|
1fb950806a | ||
|
|
073832034a | ||
|
|
57c494e8bd | ||
|
|
4a589d7a43 | ||
|
|
d39e58196f | ||
|
|
b93999646c | ||
|
|
b91be09f5c | ||
|
|
602b39d60a | ||
|
|
5e7101bfee |
@@ -69,7 +69,7 @@ export function useWorkbenchAgent({
|
||||
const frontendActionEnabledRef = useRef(false);
|
||||
const onFrontendActionRef = useRef(onFrontendAction);
|
||||
const processedEnvelopeIdsRef = useRef(new Set<string>());
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
getAgentPanelMaxWidth,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchCameraPadding,
|
||||
getWorkbenchViewportLayout
|
||||
getWorkbenchViewportLayout,
|
||||
resolveMobileWorkbenchOverlayLayout
|
||||
} from "./workbench-layout";
|
||||
|
||||
describe("workbench basemap surface tone", () => {
|
||||
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||
agentWidth: 500,
|
||||
|
||||
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
|
||||
mobileSheet: {
|
||||
halfViewportRatio: 0.52,
|
||||
fullTopGap: 68
|
||||
},
|
||||
mobileOverlay: {
|
||||
edgeInset: 12,
|
||||
gap: 8,
|
||||
dockHeight: 44,
|
||||
timelineSummaryHeight: 48
|
||||
}
|
||||
} 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(
|
||||
width: number,
|
||||
viewportWidth: number,
|
||||
|
||||
@@ -78,7 +78,8 @@ import {
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchLayoutCssVariables
|
||||
getWorkbenchLayoutCssVariables,
|
||||
resolveMobileWorkbenchOverlayLayout
|
||||
} from "./layout/workbench-layout";
|
||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||
import { exportMapViewImage } from "./map/export-view";
|
||||
@@ -220,9 +221,10 @@ export function MapWorkbenchPage({
|
||||
timelineMode,
|
||||
timelineVisible
|
||||
);
|
||||
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
|
||||
const timelineBottom = isLargeScreen
|
||||
? timelineScaleLayout.timelineBottom
|
||||
: 12;
|
||||
: mobileOverlayLayout.timelineBottom;
|
||||
|
||||
useEffect(() => {
|
||||
setTimelineMode((current) => {
|
||||
@@ -987,18 +989,30 @@ export function MapWorkbenchPage({
|
||||
onReturnNow={handleReturnOperationalNow}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
||||
<span className="font-semibold">运行时间轴</span>
|
||||
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
||||
<span className="text-slate-500">执行 {operationalSummary.active}</span>
|
||||
<span className="text-amber-700">异常 {operationalSummary.exceptions}</span>
|
||||
<span className="ml-auto text-slate-500">查看全天</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开完整运行时间轴"
|
||||
onClick={openTimelineForViewport}
|
||||
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="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<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.active}</span>
|
||||
<span className="text-amber-700">关注 {operationalSummary.attention}</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>
|
||||
</div>
|
||||
</> : null
|
||||
)}
|
||||
mapContent={(
|
||||
@@ -1018,10 +1032,13 @@ export function MapWorkbenchPage({
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 flex flex-col items-end gap-2"
|
||||
style={{ bottom: timelineScaleLayout.scaleBottom }}
|
||||
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={{
|
||||
"--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} />
|
||||
</div>
|
||||
<MapScaleLine
|
||||
@@ -1076,7 +1093,10 @@ export function MapWorkbenchPage({
|
||||
) : null}
|
||||
|
||||
{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
|
||||
{...toolbarPanelProps}
|
||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||
@@ -1107,7 +1127,10 @@ export function MapWorkbenchPage({
|
||||
) : null}
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
@@ -1166,6 +1189,7 @@ export function MapWorkbenchPage({
|
||||
events={operationalEvents}
|
||||
mode="expanded"
|
||||
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||
onModeChange={closeMobileSheet}
|
||||
onSelectEvent={handleSelectOperationalEvent}
|
||||
onClearSelection={handleClearOperationalSelection}
|
||||
onReturnNow={handleReturnOperationalNow}
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 96 }
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 24 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
@@ -28,7 +28,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: false,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 96 }
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 24 }
|
||||
},
|
||||
{
|
||||
agentOpen: true,
|
||||
|
||||
@@ -4,15 +4,33 @@ import {
|
||||
canCenterTimelineBesideMapScale,
|
||||
clusterOperationalEvents,
|
||||
createOperationalEvents,
|
||||
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||
filterOperationalEvents,
|
||||
getOperationalEventCategory,
|
||||
OPERATIONAL_TIMELINE_LAYOUT,
|
||||
resolveTimelineScaleLayout
|
||||
} from "./operational-timeline-model";
|
||||
import type { OperationalEvent } from "./operational-timeline-model";
|
||||
|
||||
const workOrder: ScheduledConditionItem = {
|
||||
id: "work-1", kind: "work_order", code: "WO-1", 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: []
|
||||
id: "work-1",
|
||||
kind: "work_order",
|
||||
code: "WO-1",
|
||||
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", () => {
|
||||
@@ -26,13 +44,57 @@ describe("operational timeline model", () => {
|
||||
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", () => {
|
||||
const normal = {
|
||||
id: "condition-normal", kind: "condition", taskId: "scada-diagnosis", sessionId: "s-1",
|
||||
scheduledAt: "2026-08-19T10:00:00.000Z", title: "诊断", summary: "正常", status: "completed",
|
||||
riskLevel: "normal", updatedAt: 1
|
||||
id: "condition-normal",
|
||||
kind: "condition",
|
||||
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;
|
||||
const abnormal = { ...normal, id: "condition-error", status: "error", riskLevel: "critical" } satisfies ScheduledConditionItem;
|
||||
|
||||
expect(createOperationalEvents([normal])).toHaveLength(0);
|
||||
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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 OperationalEventLane = "plan" | "actual" | "exception";
|
||||
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
|
||||
export type OperationalEventImportance = "normal" | "important" | "critical";
|
||||
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
|
||||
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
|
||||
|
||||
export type OperationalEvent = {
|
||||
id: string;
|
||||
@@ -42,6 +48,13 @@ export const OPERATIONAL_TIMELINE_LAYOUT = {
|
||||
overlayReservedHeight: 0
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
|
||||
plan: true,
|
||||
actual: true,
|
||||
attention: true,
|
||||
exception: true
|
||||
};
|
||||
|
||||
export type TimelineScaleLayout = {
|
||||
scaleBottom: number;
|
||||
timelineBottom: number;
|
||||
@@ -54,12 +67,11 @@ export function canCenterTimelineBesideMapScale(
|
||||
) {
|
||||
if (!visible) return true;
|
||||
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
|
||||
return businessWorkspaceWidth >=
|
||||
return (
|
||||
businessWorkspaceWidth >=
|
||||
layout.maxWidth +
|
||||
2 * (
|
||||
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
|
||||
OPERATIONAL_TIMELINE_LAYOUT.floatingGap
|
||||
);
|
||||
2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimelineScaleLayout(
|
||||
@@ -83,9 +95,11 @@ export function resolveTimelineScaleLayout(
|
||||
}
|
||||
|
||||
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
|
||||
return items.flatMap((item) =>
|
||||
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
|
||||
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
|
||||
return items
|
||||
.flatMap((item) =>
|
||||
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
|
||||
)
|
||||
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
|
||||
}
|
||||
|
||||
export function clusterOperationalEvents(
|
||||
@@ -116,32 +130,49 @@ export function getOperationalEventSummary(events: OperationalEvent[]) {
|
||||
return {
|
||||
planned: events.filter((event) => event.lane === "plan").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
|
||||
};
|
||||
}
|
||||
|
||||
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[] {
|
||||
if (item.status !== "warning" && item.status !== "error") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
id: `exception-${item.id}`,
|
||||
title: item.title,
|
||||
description: item.report?.conclusion ?? item.summary,
|
||||
lane: "exception",
|
||||
status: item.status === "error" ? "failed" : "delayed",
|
||||
importance: item.status === "error" ? "critical" : "important",
|
||||
plannedTime: item.scheduledAt,
|
||||
actualTime: item.scheduledAt,
|
||||
cursorTime: item.scheduledAt,
|
||||
correlationId: item.sessionId,
|
||||
scheduleId: item.taskId,
|
||||
conditionId: item.id,
|
||||
sourceLabel: "工况诊断",
|
||||
mapTargets: getConditionMapTargets(item)
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: `exception-${item.id}`,
|
||||
title: item.title,
|
||||
description: item.report?.conclusion ?? item.summary,
|
||||
lane: "exception",
|
||||
status: item.status === "error" ? "failed" : "delayed",
|
||||
importance: item.status === "error" ? "critical" : "important",
|
||||
plannedTime: item.scheduledAt,
|
||||
actualTime: item.scheduledAt,
|
||||
cursorTime: item.scheduledAt,
|
||||
correlationId: item.sessionId,
|
||||
scheduleId: item.taskId,
|
||||
conditionId: item.id,
|
||||
sourceLabel: "工况诊断",
|
||||
mapTargets: getConditionMapTargets(item)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
|
||||
|
||||
@@ -9,15 +9,20 @@ import {
|
||||
TriangleAlert
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
clusterOperationalEvents,
|
||||
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||
filterOperationalEvents,
|
||||
getOperationalEventSummary,
|
||||
OPERATIONAL_TIMELINE_LAYOUT,
|
||||
type OperationalEvent,
|
||||
type OperationalEventCategory,
|
||||
type OperationalEventCluster,
|
||||
type OperationalEventLane,
|
||||
type OperationalEventVisibility,
|
||||
type OperationalTimelineMode
|
||||
} from "./operational-timeline-model";
|
||||
|
||||
@@ -40,6 +45,13 @@ const LANES: Array<{ id: OperationalEventLane; label: string }> = [
|
||||
{ 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({
|
||||
date,
|
||||
cursorTime,
|
||||
@@ -53,8 +65,12 @@ export function OperationalTimeline({
|
||||
onReturnNow
|
||||
}: OperationalTimelineProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
|
||||
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
|
||||
}));
|
||||
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 cursorPosition = getDayPosition(cursorTime);
|
||||
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>
|
||||
)}
|
||||
<TimelineLegend
|
||||
visibility={eventVisibility}
|
||||
onVisibilityChange={setEventVisibility}
|
||||
/>
|
||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||
</motion.section>
|
||||
);
|
||||
@@ -94,110 +114,123 @@ export function OperationalTimeline({
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<motion.section
|
||||
aria-label="运行时间轴"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||
mobile
|
||||
? "surface-reading h-full rounded-t-xl border-t"
|
||||
: "acrylic-panel rounded-2xl border"
|
||||
)}
|
||||
initial={false}
|
||||
animate={{ height }}
|
||||
transition={sizeTransition}
|
||||
>
|
||||
<div
|
||||
aria-label="运行时间轴"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
||||
mode === "compact" && !mobile ? "h-10" : "h-12"
|
||||
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||
mobile
|
||||
? "surface-reading h-full rounded-t-xl border-t"
|
||||
: "acrylic-panel rounded-2xl border"
|
||||
)}
|
||||
initial={false}
|
||||
animate={{ height }}
|
||||
transition={sizeTransition}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<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="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
|
||||
<TimelineSummary summary={summary} />
|
||||
{selectedEvent && !mobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
title="清除当前事件选择"
|
||||
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
|
||||
>
|
||||
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||
</button>
|
||||
) : null}
|
||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{mode === "expanded" || mobile ? (
|
||||
<motion.div
|
||||
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)]"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
||||
mode === "compact" && !mobile ? "h-10" : "h-12"
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
|
||||
<span />
|
||||
{LANES.map((lane) => (
|
||||
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
|
||||
<TimeTicks />
|
||||
{LANES.map((lane) => (
|
||||
<TimelineLane
|
||||
key={lane.id}
|
||||
lane={lane.id}
|
||||
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
|
||||
style={{ left: `${cursorPosition}%` }}
|
||||
>
|
||||
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="compact-timeline"
|
||||
className="relative min-h-0 flex-1 px-3"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||
>
|
||||
<CompactTimeTicks />
|
||||
<div className="absolute bottom-2 left-3 right-3 top-4">
|
||||
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
|
||||
{clusters.map((cluster, index) => (
|
||||
<TimelineMarker
|
||||
key={cluster.id}
|
||||
cluster={cluster}
|
||||
compact
|
||||
markerIndex={index}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
|
||||
style={{ left: `${cursorPosition}%` }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
|
||||
{!mobile ? <TimelineSummary summary={summary} /> : null}
|
||||
{selectedEvent && !mobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
title="清除当前事件选择"
|
||||
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
|
||||
>
|
||||
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||
</button>
|
||||
) : null}
|
||||
<TimelineLegend
|
||||
visibility={eventVisibility}
|
||||
onVisibilityChange={setEventVisibility}
|
||||
/>
|
||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{mode === "expanded" || mobile ? (
|
||||
<motion.div
|
||||
key="expanded-timeline"
|
||||
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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||
>
|
||||
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
|
||||
<span />
|
||||
{LANES.map((lane) => (
|
||||
<span key={lane.id} className="flex items-center justify-center font-semibold">
|
||||
{lane.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
|
||||
<TimeTicks />
|
||||
{LANES.map((lane) => (
|
||||
<TimelineLane
|
||||
key={lane.id}
|
||||
lane={lane.id}
|
||||
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
|
||||
style={{ left: `${cursorPosition}%` }}
|
||||
>
|
||||
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="compact-timeline"
|
||||
className="relative min-h-0 flex-1 px-3"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||
>
|
||||
<CompactTimeTicks />
|
||||
<div className="absolute bottom-2 left-3 right-3 top-4">
|
||||
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
|
||||
{clusters.map((cluster, index) => (
|
||||
<TimelineMarker
|
||||
key={cluster.id}
|
||||
cluster={cluster}
|
||||
compact
|
||||
markerIndex={index}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
|
||||
style={{ left: `${cursorPosition}%` }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.section>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -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)]",
|
||||
event.lane === "actual" &&
|
||||
"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)]",
|
||||
event.status === "failed" &&
|
||||
"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}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72 bg-white px-3 py-2 text-slate-900 shadow-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"rounded-md px-1.5 py-0.5 text-[10px] font-semibold",
|
||||
event.lane === "plan" && "bg-blue-50 text-blue-700",
|
||||
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"
|
||||
)}>
|
||||
{operationalEventNatureLabel(event)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">{event.sourceLabel}</span>
|
||||
</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
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
className={cn(
|
||||
"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)]"
|
||||
)}
|
||||
>
|
||||
<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)}
|
||||
</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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -315,8 +384,8 @@ function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
|
||||
}
|
||||
|
||||
function operationalEventNatureLabel(event: OperationalEvent) {
|
||||
if (event.status === "failed") return "关键异常";
|
||||
if (event.lane === "exception") return "运行偏差";
|
||||
if (event.status === "failed") return "异常";
|
||||
if (event.lane === "exception") return "关注";
|
||||
if (event.lane === "actual") return "实际执行";
|
||||
return "计划任务";
|
||||
}
|
||||
@@ -324,9 +393,18 @@ function operationalEventNatureLabel(event: OperationalEvent) {
|
||||
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
|
||||
return (
|
||||
<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>执行 <strong className="text-emerald-700">{summary.active}</strong></span>
|
||||
<span>异常 <strong className="text-amber-700">{summary.exceptions}</strong></span>
|
||||
<span>
|
||||
计划 <strong className="text-blue-700">{summary.planned}</strong>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -341,7 +419,7 @@ function TimelineActions({
|
||||
onReturnNow: () => void;
|
||||
}) {
|
||||
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
|
||||
type="button"
|
||||
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() {
|
||||
return (
|
||||
<div className="relative text-[9px] text-slate-600">
|
||||
@@ -414,7 +546,9 @@ function CompactTimeTicks() {
|
||||
}
|
||||
|
||||
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"]) {
|
||||
@@ -428,7 +562,11 @@ function getDayPosition(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) {
|
||||
|
||||
@@ -7,52 +7,46 @@ import {
|
||||
Clock3,
|
||||
Database,
|
||||
FileSearch,
|
||||
Focus,
|
||||
Map as MapIcon,
|
||||
Minimize2,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
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 {
|
||||
AnalysisArtifact,
|
||||
AnalysisArtifactAction,
|
||||
AnalysisBlock,
|
||||
AnalysisMetric,
|
||||
ArtifactRequestedView,
|
||||
WorkbenchSurfaceMode
|
||||
AnalysisMetric
|
||||
} from "./workspace-model";
|
||||
|
||||
type AnalysisArtifactWorkspaceProps = {
|
||||
artifact: AnalysisArtifact;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
onSetView: (view: ArtifactRequestedView) => void;
|
||||
onCollapse: () => void;
|
||||
onDestroy: () => void;
|
||||
};
|
||||
|
||||
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||
|
||||
export function AnalysisArtifactWorkspace({
|
||||
artifact,
|
||||
surfaceMode,
|
||||
mapSplitAvailable,
|
||||
onSetView,
|
||||
onCollapse,
|
||||
onDestroy
|
||||
}: AnalysisArtifactWorkspaceProps) {
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
|
||||
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid");
|
||||
const selectedBlock = useMemo(
|
||||
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
|
||||
[artifact.blocks, selectedBlockId]
|
||||
const evidenceBlocks = artifact.blocks.filter(
|
||||
(block): block is EvidenceBlock => block.kind !== "metric-grid"
|
||||
);
|
||||
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBlockId(null);
|
||||
@@ -116,11 +110,6 @@ export function AnalysisArtifactWorkspace({
|
||||
|
||||
<ArtifactActionBar
|
||||
artifact={artifact}
|
||||
surfaceMode={surfaceMode}
|
||||
mapSplitAvailable={mapSplitAvailable}
|
||||
detailOpen={Boolean(selectedBlock)}
|
||||
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
|
||||
onSetView={onSetView}
|
||||
onCollapse={onCollapse}
|
||||
onDestroy={onDestroy}
|
||||
/>
|
||||
@@ -176,7 +165,7 @@ function AnalysisBlockView({
|
||||
selected,
|
||||
onSelect
|
||||
}: {
|
||||
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
|
||||
block: EvidenceBlock;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
@@ -198,11 +187,11 @@ function AnalysisBlockView({
|
||||
>
|
||||
<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}详情`}
|
||||
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" />
|
||||
</span>
|
||||
<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") {
|
||||
return (
|
||||
<div className="h-[258px] min-h-0">
|
||||
@@ -280,14 +269,28 @@ function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "m
|
||||
|
||||
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
|
||||
return (
|
||||
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
|
||||
<div className="surface-well rounded-xl border px-4 py-3.5">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" />数据来源</h3>
|
||||
<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>
|
||||
<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="flex min-w-0 items-start gap-3 px-4 py-3">
|
||||
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
|
||||
<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 className="material-tone-warning rounded-xl border px-4 py-3.5">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" />分析限制</h3>
|
||||
<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>
|
||||
<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 shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
@@ -299,79 +302,140 @@ function ArtifactDetailDrawer({
|
||||
onClose
|
||||
}: {
|
||||
artifact: AnalysisArtifact;
|
||||
block: AnalysisBlock;
|
||||
block: EvidenceBlock;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const blockFacts = getEvidenceBlockFacts(block);
|
||||
|
||||
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)]">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
|
||||
<div>
|
||||
<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>
|
||||
<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 border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
<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>
|
||||
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
|
||||
</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">
|
||||
<X size={18} aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭证据详情"
|
||||
onClick={onClose}
|
||||
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-5 p-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500">成果结论</p>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500">证据说明</p>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p>
|
||||
</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>
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
<section aria-labelledby="evidence-purpose-heading">
|
||||
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500">证据说明</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>
|
||||
<dl className="mt-2 divide-y divide-slate-200">
|
||||
{blockFacts.map((fact) => (
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
</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({
|
||||
artifact,
|
||||
surfaceMode,
|
||||
mapSplitAvailable,
|
||||
detailOpen,
|
||||
onToggleDetail,
|
||||
onSetView,
|
||||
onCollapse,
|
||||
onDestroy
|
||||
}: {
|
||||
artifact: AnalysisArtifact;
|
||||
surfaceMode: WorkbenchSurfaceMode;
|
||||
mapSplitAvailable: boolean;
|
||||
detailOpen: boolean;
|
||||
onToggleDetail: () => void;
|
||||
onSetView: (view: ArtifactRequestedView) => void;
|
||||
onCollapse: () => void;
|
||||
onDestroy: () => void;
|
||||
}) {
|
||||
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">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{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>
|
||||
<span />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{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={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={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} 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>
|
||||
</footer>
|
||||
);
|
||||
@@ -382,9 +446,9 @@ function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactA
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
|
||||
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";
|
||||
|
||||
export function ArtifactPeekBar({
|
||||
@@ -17,7 +21,7 @@ export function ArtifactPeekBar({
|
||||
<aside
|
||||
aria-label="Agent 成果预览"
|
||||
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",
|
||||
surfaceMode === "map_split" && "2xl:left-[25%]"
|
||||
)}
|
||||
@@ -36,9 +40,9 @@ export function ArtifactPeekBar({
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
@@ -46,9 +50,9 @@ export function ArtifactPeekBar({
|
||||
aria-label="销毁预览成果"
|
||||
title="销毁预览成果"
|
||||
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>
|
||||
</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 type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
||||
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
|
||||
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 {
|
||||
AnalysisArtifact,
|
||||
ArtifactRequestedView,
|
||||
@@ -46,10 +50,28 @@ export function WorkbenchMainFrame({
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
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
|
||||
? { duration: 0 }
|
||||
: { 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 (
|
||||
<div
|
||||
@@ -77,39 +99,41 @@ export function WorkbenchMainFrame({
|
||||
>
|
||||
{mapContent}
|
||||
{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>
|
||||
|
||||
{activeArtifact ? (
|
||||
<section
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
className={cn(
|
||||
"relative z-30 min-h-0 min-w-0 overflow-hidden",
|
||||
!showArtifact && "hidden"
|
||||
)}
|
||||
>
|
||||
<AnalysisArtifactWorkspace
|
||||
artifact={activeArtifact}
|
||||
surfaceMode={surfaceMode}
|
||||
mapSplitAvailable={mapSplitAvailable}
|
||||
onSetView={onSetArtifactView}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{activeArtifact ? (
|
||||
<motion.section
|
||||
key={activeArtifact.id}
|
||||
id="artifact-workspace"
|
||||
aria-label={`${activeArtifact.title}工作区`}
|
||||
aria-hidden={!showArtifact}
|
||||
className={cn(
|
||||
"z-30 min-h-0 min-w-0 overflow-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
|
||||
artifact={activeArtifact}
|
||||
onCollapse={onCollapseArtifact}
|
||||
onDestroy={onDestroyArtifact}
|
||||
/>
|
||||
</motion.section>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{pendingArtifact ? (
|
||||
<ArtifactPeekBar
|
||||
@@ -119,6 +143,54 @@ export function WorkbenchMainFrame({
|
||||
onDestroy={onDestroyPendingArtifact}
|
||||
/>
|
||||
) : 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>
|
||||
|
||||
<motion.div
|
||||
|
||||
@@ -40,7 +40,8 @@ describe("Keycloak authentication", () => {
|
||||
expect(client.init).toHaveBeenCalledWith({
|
||||
onLoad: "login-required",
|
||||
flow: "standard",
|
||||
pkceMethod: "S256"
|
||||
pkceMethod: "S256",
|
||||
checkLoginIframe: false
|
||||
});
|
||||
expect(authentication.user).toEqual({
|
||||
name: "张调度",
|
||||
|
||||
@@ -59,7 +59,8 @@ export async function initializeAuthentication(
|
||||
const authenticated = await keycloak.init({
|
||||
onLoad: "login-required",
|
||||
flow: "standard",
|
||||
pkceMethod: "S256"
|
||||
pkceMethod: "S256",
|
||||
checkLoginIframe: false
|
||||
});
|
||||
|
||||
if (!authenticated) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
Reference in New Issue
Block a user