fix: animate analysis workspace transitions

This commit is contained in:
2026-08-19 17:54:37 +08:00
parent 1fb950806a
commit 229cb0def2
2 changed files with 72 additions and 83 deletions
@@ -7,37 +7,29 @@ import {
Clock3, Clock3,
Database, Database,
FileSearch, FileSearch,
Focus,
Minimize2, Minimize2,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
Trash2, Trash2,
X X
} from "lucide-react"; } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, 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 { import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME, ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_ICON_BUTTON_CLASS_NAME, ARTIFACT_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME, ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
} from "./artifact-view-control-styles"; } 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;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void; onCollapse: () => void;
onDestroy: () => void; onDestroy: () => void;
}; };
@@ -46,8 +38,6 @@ type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
export function AnalysisArtifactWorkspace({ export function AnalysisArtifactWorkspace({
artifact, artifact,
surfaceMode,
onSetView,
onCollapse, onCollapse,
onDestroy onDestroy
}: AnalysisArtifactWorkspaceProps) { }: AnalysisArtifactWorkspaceProps) {
@@ -77,10 +67,7 @@ export function AnalysisArtifactWorkspace({
className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900" className="acrylic-panel relative m-3 grid h-[calc(100%-1.5rem)] min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-2xl border text-slate-900"
lang="zh-CN" lang="zh-CN"
> >
<ArtifactHeader <ArtifactHeader artifact={artifact} />
artifact={artifact}
reserveViewControlSpace={surfaceMode === "focus" && artifact.mapRelation !== "none"}
/>
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5"> <div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-4 xl:px-5">
<section aria-labelledby="artifact-summary-heading"> <section aria-labelledby="artifact-summary-heading">
@@ -123,8 +110,6 @@ export function AnalysisArtifactWorkspace({
<ArtifactActionBar <ArtifactActionBar
artifact={artifact} artifact={artifact}
surfaceMode={surfaceMode}
onSetView={onSetView}
onCollapse={onCollapse} onCollapse={onCollapse}
onDestroy={onDestroy} onDestroy={onDestroy}
/> />
@@ -140,20 +125,9 @@ export function AnalysisArtifactWorkspace({
); );
} }
function ArtifactHeader({ function ArtifactHeader({ artifact }: { artifact: AnalysisArtifact }) {
artifact,
reserveViewControlSpace
}: {
artifact: AnalysisArtifact;
reserveViewControlSpace: boolean;
}) {
return ( return (
<header <header className="surface-control relative z-10 border-b px-4 py-4 xl:px-5">
className={cn(
"surface-control relative z-10 border-b px-4 pb-4 xl:px-5",
reserveViewControlSpace ? "pt-14" : "pt-4"
)}
>
<div className="flex items-start justify-between gap-5"> <div className="flex items-start justify-between gap-5">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500"> <div className="flex flex-wrap items-center gap-2 text-xs font-medium text-slate-500">
@@ -448,36 +422,16 @@ function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"
function ArtifactActionBar({ function ArtifactActionBar({
artifact, artifact,
surfaceMode,
onSetView,
onCollapse, onCollapse,
onDestroy onDestroy
}: { }: {
artifact: AnalysisArtifact; artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void; onCollapse: () => void;
onDestroy: () => void; onDestroy: () => void;
}) { }) {
const prefersReducedMotion = useReducedMotion();
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" && surfaceMode === "map_split" ? (
<motion.div
className={ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME}
initial={prefersReducedMotion ? false : { opacity: 0, y: 4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
>
<button type="button" onClick={() => onSetView("focus")} className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}>
<Focus size={13} aria-hidden="true" />
</button>
</motion.div>
) : null}
</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={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} 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>
@@ -1,4 +1,4 @@
import { ArrowLeft, Map as MapIcon } 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";
@@ -50,19 +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( const showArtifactViewControl = Boolean(
activeArtifact && activeArtifact &&
activeArtifact.mapRelation !== "none" && activeArtifact.mapRelation !== "none"
(surfaceMode === "focus" || surfaceMode === "map_only")
); );
const mapOnly = surfaceMode === "map_only"; 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 const viewControlTransition = prefersReducedMotion
? { duration: 0 } ? { duration: 0 }
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const }; : { 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
@@ -92,25 +101,39 @@ export function WorkbenchMainFrame({
{mapOverlay} {mapOverlay}
</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}
onSetView={onSetArtifactView}
onCollapse={onCollapseArtifact} onCollapse={onCollapseArtifact}
onDestroy={onDestroyArtifact} onDestroy={onDestroyArtifact}
/> />
</section> </motion.section>
) : null} ) : null}
</AnimatePresence>
{pendingArtifact ? ( {pendingArtifact ? (
<ArtifactPeekBar <ArtifactPeekBar
@@ -125,31 +148,43 @@ export function WorkbenchMainFrame({
{showArtifactViewControl ? ( {showArtifactViewControl ? (
<motion.div <motion.div
className={cn( className={cn(
"pointer-events-auto absolute left-6 top-4 z-40 lg:left-[max(1.5rem,calc(6rem-var(--workbench-agent-current-width)))]", "pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
)} )}
initial={prefersReducedMotion ? false : { opacity: 0, y: 6, scale: 0.98 }} initial={prefersReducedMotion
animate={{ opacity: 1, y: 0, scale: 1 }} ? 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 }} exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition} transition={viewControlTransition}
> >
<button <button
type="button" type="button"
aria-label={mapOnly ? "返回分析成果" : "显示地图"} aria-label={viewControlLabel}
onClick={() => onSetArtifactView(mapOnly ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME} className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
> >
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
<motion.span <motion.span
key={mapOnly ? "return-analysis" : "show-map"} key={surfaceMode}
className="inline-flex items-center gap-1.5" className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }} initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }} exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
transition={viewControlTransition} transition={viewControlTransition}
> >
{mapOnly ? <ArrowLeft size={13} aria-hidden="true" /> : <MapIcon size={13} aria-hidden="true" />} {mapOnly ? (
{mapOnly ? "返回分析成果" : "显示地图"} <ArrowLeft size={13} aria-hidden="true" />
) : mapSplit ? (
<Focus size={13} aria-hidden="true" />
) : (
<MapIcon size={13} aria-hidden="true" />
)}
{viewControlLabel}
</motion.span> </motion.span>
</AnimatePresence> </AnimatePresence>
</button> </button>