feat(frontend): enhance workflow visualizer layout, report page UI and add pending task doc
- Refactor WorkflowVisualizer to use rank-based grid layout for better readability of parallel tasks. - Improve edge rendering in visualizer with highlighting logic and curved lines. - Update ReportPage tabs styling to be more distinct and card-like. - Implement collapsible sidebar for Workflow Status visualizer with vertical text support. - Add formatNodeName utility for cleaner task name display. - Fix CSS issues in index.css. - Add documentation for pending task: adding display_name to workflow DTOs.
This commit is contained in:
parent
b8eab4dfd5
commit
fbfb820853
39
docs/tasks/pending/20251127_add_task_display_names.md
Normal file
39
docs/tasks/pending/20251127_add_task_display_names.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# [Pending] 为工作流任务添加人类可读名称 (Display Name)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
目前,前端在显示任务名称时使用的是任务 ID(例如 `analysis:news_analysis`,或者是经过简单格式化后的 `news analysis`)。然而,真正的人类可读名称(例如 “新闻分析”)是定义在 `AnalysisTemplate` 配置中的,但这些名称并没有通过工作流事件传播到 `WorkflowOrchestrator` 或前端。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
确保前端可以在工作流可视化图表(Visualizer)和标签页(Tab Headers)中显示模板中定义的本地化/人类可读的任务名称。
|
||||||
|
|
||||||
|
## 需要的变更
|
||||||
|
|
||||||
|
### 1. Common Contracts (`services/common-contracts`)
|
||||||
|
- **文件**: `src/workflow_types.rs` 或 `src/messages.rs`
|
||||||
|
- **行动**: 更新 `TaskNode` 结构体(用于 `WorkflowStateSnapshot`),增加一个 `display_name` (`Option<String>`) 字段。
|
||||||
|
- **行动**: (可选) 如果我们需要实时更新也携带名称,可以更新 `WorkflowTaskEvent`,虽然对于静态拓扑来说,快照(Snapshot)通常就足够了。
|
||||||
|
|
||||||
|
### 2. Workflow Orchestrator Service (`services/workflow-orchestrator-service`)
|
||||||
|
- **文件**: `src/dag_scheduler.rs`
|
||||||
|
- **行动**: 在通过 `add_node` 添加节点时,接受一个 `display_name` 参数。
|
||||||
|
- **文件**: `src/workflow.rs`
|
||||||
|
- **行动**: 在 `build_dag` 函数中,遍历 `template.modules` 时:
|
||||||
|
- 提取 `module_config.name`(例如 “新闻分析”)。
|
||||||
|
- 在创建 DAG 节点时传递这个名称。
|
||||||
|
|
||||||
|
### 3. Frontend (`frontend`)
|
||||||
|
- **文件**: `src/types/workflow.ts`
|
||||||
|
- **行动**: 更新 `TaskNode` 接口以匹配新的后端 DTO。
|
||||||
|
- **文件**: `src/components/workflow/WorkflowVisualizer.tsx` & `src/pages/ReportPage.tsx`
|
||||||
|
- **行动**: 如果 `node.display_name` 存在,则优先使用它;否则回退到使用 `formatNodeName(node.id)`。
|
||||||
|
|
||||||
|
## 替代方案 / 临时方案 (纯前端)
|
||||||
|
由于前端已经(通过 `useAnalysisTemplates` hook)获取了 `AnalysisTemplate`,我们可以:
|
||||||
|
1. 从 URL 参数中获取当前的 `templateId`。
|
||||||
|
2. 查找对应的模板定义。
|
||||||
|
3. 创建一个映射表:`module_id -> module_name`。
|
||||||
|
4. 在 `ReportPage` 和 `WorkflowVisualizer` 中使用此映射表来动态解析名称。
|
||||||
|
|
||||||
|
## 优先级
|
||||||
|
中等 - 能够显著改善用户体验 (UX),但现有功能不受影响。
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback, useState } from 'react';
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
@ -42,6 +42,9 @@ const WorkflowNode = ({ data, selected }: { data: { label: string, status: TaskS
|
|||||||
[schemas.TaskStatus.enum.Skipped]: 'border-gray-200 bg-gray-50/5 opacity-60',
|
[schemas.TaskStatus.enum.Skipped]: 'border-gray-200 bg-gray-50/5 opacity-60',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove 'analysis:' or 'fetch:' prefix for cleaner display
|
||||||
|
const displayLabel = data.label.replace(/^(analysis:|fetch:)/, '').replace(/_/g, ' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"px-4 py-2 rounded-lg border shadow-sm min-w-[180px] transition-all duration-300 cursor-pointer bg-background",
|
"px-4 py-2 rounded-lg border shadow-sm min-w-[180px] transition-all duration-300 cursor-pointer bg-background",
|
||||||
@ -53,7 +56,7 @@ const WorkflowNode = ({ data, selected }: { data: { label: string, status: TaskS
|
|||||||
<StatusIcon status={data.status} />
|
<StatusIcon status={data.status} />
|
||||||
<div className="text-xs font-mono uppercase text-muted-foreground">{data.type}</div>
|
<div className="text-xs font-mono uppercase text-muted-foreground">{data.type}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-sm truncate" title={data.label}>{data.label}</div>
|
<div className="font-medium text-sm truncate capitalize" title={displayLabel}>{displayLabel}</div>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground/50 !w-2 !h-2" />
|
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground/50 !w-2 !h-2" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -63,55 +66,113 @@ const nodeTypes = {
|
|||||||
taskNode: WorkflowNode,
|
taskNode: WorkflowNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Layout Helper with ELK ---
|
// --- Custom Grid Layout (Manual) ---
|
||||||
const elk = new ELK();
|
const useGridLayout = () => {
|
||||||
|
const getLayoutedElements = useCallback(async (nodes: Node[], edges: Edge[], dagNodes: any[], dagEdges: any[]) => {
|
||||||
|
// Constants
|
||||||
|
const NODE_WIDTH = 240; // Increased width for long names
|
||||||
|
const NODE_HEIGHT = 64;
|
||||||
|
const X_SPACING = 40;
|
||||||
|
const Y_SPACING = 80;
|
||||||
|
const MAX_COLS = 3; // Max columns per row
|
||||||
|
|
||||||
const useLayout = () => {
|
// 1. Topological Sort (Rank-Based Grid)
|
||||||
const getLayoutedElements = useCallback(async (nodes: Node[], edges: Edge[]) => {
|
// Calculate Ranks
|
||||||
const graph = {
|
const ranks = new Map<string, number>();
|
||||||
id: 'root',
|
const nodeMap = new Map(dagNodes.map(n => [n.id, n]));
|
||||||
layoutOptions: {
|
const incomingEdges = new Map<string, string[]>();
|
||||||
'elk.algorithm': 'layered',
|
|
||||||
'elk.direction': 'DOWN',
|
dagNodes.forEach(n => incomingEdges.set(n.id, []));
|
||||||
'elk.spacing.nodeNode': '60', // Horizontal spacing
|
dagEdges.forEach(e => incomingEdges.get(e.to)?.push(e.from));
|
||||||
'elk.layered.spacing.nodeNodeBetweenLayers': '80', // Vertical spacing
|
|
||||||
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
// Rank 0: Roots (No incoming edges)
|
||||||
// Optimize for fewer edge bends/crossings
|
const roots = dagNodes.filter(n => (incomingEdges.get(n.id)?.length || 0) === 0);
|
||||||
'elk.edgeRouting': 'SPLINES',
|
roots.forEach(n => ranks.set(n.id, 0));
|
||||||
},
|
|
||||||
children: nodes.map((node) => ({
|
// Calculate Ranks for others (Longest Path)
|
||||||
id: node.id,
|
// Simple approach: For small DAGs, we can iterate.
|
||||||
width: 180, // Match min-w-[180px]
|
// Correct approach: Kahn's algorithm or DFS with memoization
|
||||||
height: 64, // Approx height
|
|
||||||
})),
|
const getRank = (nodeId: string, visited = new Set<string>()): number => {
|
||||||
edges: edges.map((edge) => ({
|
if (ranks.has(nodeId)) return ranks.get(nodeId)!;
|
||||||
id: edge.id,
|
if (visited.has(nodeId)) return 0; // Cycle detected, fallback
|
||||||
sources: [edge.source],
|
|
||||||
targets: [edge.target],
|
visited.add(nodeId);
|
||||||
})),
|
const parents = incomingEdges.get(nodeId) || [];
|
||||||
|
if (parents.length === 0) return 0;
|
||||||
|
|
||||||
|
let maxParentRank = 0;
|
||||||
|
for (const pId of parents) {
|
||||||
|
maxParentRank = Math.max(maxParentRank, getRank(pId, new Set(visited)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = maxParentRank + 1;
|
||||||
|
ranks.set(nodeId, rank);
|
||||||
|
return rank;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
dagNodes.forEach(n => getRank(n.id));
|
||||||
const layoutedGraph = await elk.layout(graph);
|
|
||||||
|
|
||||||
const layoutedNodes = nodes.map((node) => {
|
// Special Handling:
|
||||||
const nodeElk = layoutedGraph.children?.find((n) => n.id === node.id);
|
// 'company_profile' relies on DataFetch (Rank 0), so it gets Rank 1.
|
||||||
return {
|
// 'final_conclusion' relies on many nodes.
|
||||||
...node,
|
// If 'final_conclusion' is the true sink, ensure it's at the bottom.
|
||||||
targetPosition: Position.Top,
|
// Let's identify the max rank.
|
||||||
sourcePosition: Position.Bottom,
|
let maxRank = 0;
|
||||||
position: {
|
ranks.forEach(r => maxRank = Math.max(maxRank, r));
|
||||||
x: nodeElk?.x || 0,
|
|
||||||
y: nodeElk?.y || 0,
|
// Group nodes by Rank
|
||||||
},
|
const nodesByRank: Record<number, any[]> = {};
|
||||||
};
|
dagNodes.forEach(n => {
|
||||||
|
const r = ranks.get(n.id) || 0;
|
||||||
|
if (!nodesByRank[r]) nodesByRank[r] = [];
|
||||||
|
nodesByRank[r].push(n);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { nodes: layoutedNodes, edges };
|
// Sort nodes within ranks for consistency
|
||||||
} catch (e) {
|
Object.values(nodesByRank).forEach(list => list.sort((a, b) => a.id.localeCompare(b.id)));
|
||||||
console.error('ELK Layout Error:', e);
|
|
||||||
return { nodes, edges };
|
// 2. Assign Grid Positions
|
||||||
|
const layoutedNodes = [...nodes];
|
||||||
|
let currentY = 0;
|
||||||
|
|
||||||
|
// Iterate through ranks 0 to maxRank
|
||||||
|
for (let r = 0; r <= maxRank; r++) {
|
||||||
|
const rankNodes = nodesByRank[r] || [];
|
||||||
|
if (rankNodes.length === 0) continue;
|
||||||
|
|
||||||
|
// Grid layout for this rank
|
||||||
|
// If it's Rank 0 (Root) or Max Rank (Sink), maybe center single row?
|
||||||
|
// Actually, just applying grid logic uniformly works well.
|
||||||
|
|
||||||
|
for (let i = 0; i < rankNodes.length; i += MAX_COLS) {
|
||||||
|
const chunk = rankNodes.slice(i, i + MAX_COLS);
|
||||||
|
const rowWidthTotal = chunk.length * NODE_WIDTH + (chunk.length - 1) * X_SPACING;
|
||||||
|
const startX = -(rowWidthTotal / 2) + (NODE_WIDTH / 2);
|
||||||
|
|
||||||
|
chunk.forEach((node, idx) => {
|
||||||
|
const n = layoutedNodes.find(ln => ln.id === node.id);
|
||||||
|
if (n) {
|
||||||
|
n.position = {
|
||||||
|
x: startX + idx * (NODE_WIDTH + X_SPACING),
|
||||||
|
y: currentY
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentY += (NODE_HEIGHT + Y_SPACING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Simplified Edges
|
||||||
|
// Use default bezier curve for smoother look
|
||||||
|
const layoutedEdges = edges.map(e => ({
|
||||||
|
...e,
|
||||||
|
type: 'default',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { nodes: layoutedNodes, edges: layoutedEdges, height: currentY };
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { getLayoutedElements };
|
return { getLayoutedElements };
|
||||||
@ -123,7 +184,8 @@ export function WorkflowVisualizer() {
|
|||||||
const { dag, tasks, setActiveTab, activeTab } = useWorkflowStore();
|
const { dag, tasks, setActiveTab, activeTab } = useWorkflowStore();
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
const { getLayoutedElements } = useLayout();
|
const [graphHeight, setGraphHeight] = useState<number>(0);
|
||||||
|
const { getLayoutedElements } = useGridLayout(); // Use custom layout
|
||||||
|
|
||||||
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||||
if (node.data.type === schemas.TaskType.enum.DataFetch) {
|
if (node.data.type === schemas.TaskType.enum.DataFetch) {
|
||||||
@ -147,6 +209,7 @@ export function WorkflowVisualizer() {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
type: 'taskNode',
|
type: 'taskNode',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
|
draggable: false, // Disable node dragging
|
||||||
data: {
|
data: {
|
||||||
label: node.name,
|
label: node.name,
|
||||||
status,
|
status,
|
||||||
@ -156,24 +219,61 @@ export function WorkflowVisualizer() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Create Edges (Use Default Bezier for better visuals than Smoothstep with hierarchical)
|
// 2. Create Edges
|
||||||
const initialEdges: Edge[] = dag.edges.map(edge => {
|
const initialEdges: Edge[] = dag.edges.map(edge => {
|
||||||
|
// Determine if this edge should be highlighted
|
||||||
|
let isHighlighted = false;
|
||||||
|
let isDimmed = false;
|
||||||
|
|
||||||
|
if (activeTab === 'overview') {
|
||||||
|
// In overview, show all edges but subtly
|
||||||
|
isHighlighted = false;
|
||||||
|
isDimmed = false;
|
||||||
|
} else if (activeTab === 'data') {
|
||||||
|
// If 'data' tab is active, highlight edges connected to DataFetch nodes
|
||||||
|
const fromNode = dag.nodes.find(n => n.id === edge.from);
|
||||||
|
const toNode = dag.nodes.find(n => n.id === edge.to);
|
||||||
|
if (fromNode?.type === schemas.TaskType.enum.DataFetch || toNode?.type === schemas.TaskType.enum.DataFetch) {
|
||||||
|
isHighlighted = true;
|
||||||
|
} else {
|
||||||
|
isDimmed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Specific node selected
|
||||||
|
if (edge.from === activeTab || edge.to === activeTab) {
|
||||||
|
isHighlighted = true;
|
||||||
|
} else {
|
||||||
|
isDimmed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = isHighlighted
|
||||||
|
? { stroke: '#3b82f6', strokeWidth: 2, opacity: 1 } // Highlight: Blue, Thick, Opaque
|
||||||
|
: isDimmed
|
||||||
|
? { stroke: '#e2e8f0', strokeWidth: 1, opacity: 0.1 } // Dimmed: Faint
|
||||||
|
: { stroke: '#94a3b8', strokeWidth: 1, opacity: 0.4 }; // Normal (Overview): Medium gray
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${edge.from}-${edge.to}`,
|
id: `${edge.from}-${edge.to}`,
|
||||||
source: edge.from,
|
source: edge.from,
|
||||||
target: edge.to,
|
target: edge.to,
|
||||||
type: 'default', // Bezier curve
|
markerEnd: {
|
||||||
markerEnd: { type: MarkerType.ArrowClosed },
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: style.stroke
|
||||||
|
},
|
||||||
animated: tasks[edge.from]?.status === schemas.TaskStatus.enum.Running || tasks[edge.to]?.status === schemas.TaskStatus.enum.Running,
|
animated: tasks[edge.from]?.status === schemas.TaskStatus.enum.Running || tasks[edge.to]?.status === schemas.TaskStatus.enum.Running,
|
||||||
style: { stroke: '#64748b', strokeWidth: 1.5 }
|
zIndex: isHighlighted ? 10 : 0,
|
||||||
|
style
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Apply Layout
|
// 3. Apply Layout
|
||||||
const { nodes: layoutedNodes, edges: layoutedEdges } = await getLayoutedElements(initialNodes, initialEdges);
|
// @ts-ignore - getLayoutedElements returns extra height prop
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges, height } = await getLayoutedElements(initialNodes, initialEdges, dag.nodes, dag.edges);
|
||||||
|
|
||||||
setNodes(layoutedNodes);
|
setNodes(layoutedNodes);
|
||||||
setEdges(layoutedEdges);
|
setEdges(layoutedEdges);
|
||||||
|
if (height) setGraphHeight(height);
|
||||||
};
|
};
|
||||||
|
|
||||||
createGraph();
|
createGraph();
|
||||||
@ -192,9 +292,20 @@ export function WorkflowVisualizer() {
|
|||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
attributionPosition="bottom-right"
|
attributionPosition="bottom-right"
|
||||||
|
panOnDrag={false} // Lock panning (X/Y) - user scroll does vertical
|
||||||
|
zoomOnScroll={false} // Lock zoom
|
||||||
|
zoomOnPinch={false}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
panOnScroll={true} // Allow vertical scroll to pan Y
|
||||||
|
preventScrolling={false} // Allow browser scroll if needed, but usually false for canvas
|
||||||
|
nodesConnectable={false} // Disable connecting nodes
|
||||||
|
translateExtent={[
|
||||||
|
[-600, -100],
|
||||||
|
[600, Math.max(graphHeight + 100, 500)] // Ensure at least some height
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Background color="#94a3b8" gap={20} size={1} />
|
<Background color="#94a3b8" gap={20} size={1} />
|
||||||
<Controls position="top-right" />
|
{/* Controls removed to force simpler view */}
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,3 +37,7 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.writing-vertical-lr {
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
}
|
||||||
|
|||||||
@ -5,3 +5,16 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatNodeName(name: string): string {
|
||||||
|
// If the name looks like an ID (contains underscores or colons), try to format it nicely
|
||||||
|
// Otherwise, assume it's already a human-readable name (like "新闻分析") and return as is.
|
||||||
|
|
||||||
|
// Basic heuristic: If it has Chinese characters, it's probably already a name.
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(name)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for IDs: remove prefix, replace underscores
|
||||||
|
return name.replace(/^(analysis:|fetch:)/, '').replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { WorkflowVisualizer } from '@/components/workflow/WorkflowVisualizer';
|
import { WorkflowVisualizer } from '@/components/workflow/WorkflowVisualizer';
|
||||||
import { useWorkflowStore } from '@/stores/useWorkflowStore';
|
import { useWorkflowStore } from '@/stores/useWorkflowStore';
|
||||||
import { TaskStatus, schemas } from '@/api/schema.gen';
|
import { TaskStatus, schemas } from '@/api/schema.gen';
|
||||||
import { Loader2, CheckCircle2, AlertCircle, Clock } from 'lucide-react';
|
import { Loader2, CheckCircle2, AlertCircle, Clock, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@ -16,6 +16,7 @@ import { useAutoScroll } from '@/hooks/useAutoScroll';
|
|||||||
import { RealtimeLogs } from '@/components/RealtimeLogs';
|
import { RealtimeLogs } from '@/components/RealtimeLogs';
|
||||||
import { WorkflowStatus, ConnectionStatus, TaskState } from '@/types/workflow';
|
import { WorkflowStatus, ConnectionStatus, TaskState } from '@/types/workflow';
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { cn, formatNodeName } from '@/lib/utils';
|
||||||
|
|
||||||
export function ReportPage() {
|
export function ReportPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@ -23,6 +24,7 @@ export function ReportPage() {
|
|||||||
const symbol = searchParams.get('symbol');
|
const symbol = searchParams.get('symbol');
|
||||||
const market = searchParams.get('market');
|
const market = searchParams.get('market');
|
||||||
const templateId = searchParams.get('templateId');
|
const templateId = searchParams.get('templateId');
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
initialize,
|
initialize,
|
||||||
@ -93,37 +95,99 @@ export function ReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Grid */}
|
{/* Main Content Grid */}
|
||||||
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
|
<div className="flex gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
{/* Left Col: Visualizer (4 cols) */}
|
{/* Left Col: Visualizer */}
|
||||||
<div className="col-span-4 flex flex-col gap-4 min-h-0 h-full">
|
<div className={cn(
|
||||||
<Card className="flex-1 flex flex-col min-h-0 py-0 gap-0">
|
"flex flex-col gap-4 min-h-0 h-full transition-all duration-300 ease-in-out",
|
||||||
<CardHeader className="py-3 px-4 shrink-0">
|
isSidebarCollapsed ? "w-[60px]" : "w-[33%] min-w-[350px]"
|
||||||
<CardTitle className="text-sm font-medium">Workflow Status</CardTitle>
|
)}>
|
||||||
|
<Card className="flex-1 flex flex-col min-h-0 py-0 gap-0 overflow-hidden">
|
||||||
|
<CardHeader className={cn("py-3 px-4 shrink-0 flex flex-row items-center space-y-0 transition-all duration-300", isSidebarCollapsed ? "h-full flex-col justify-start py-4 gap-4" : "h-[60px] justify-between")}>
|
||||||
|
{!isSidebarCollapsed ? (
|
||||||
|
<CardTitle className="text-sm font-medium truncate">Workflow Status</CardTitle>
|
||||||
|
) : (
|
||||||
|
<div className="writing-vertical-lr transform rotate-180 text-sm font-medium whitespace-nowrap tracking-wide text-muted-foreground">
|
||||||
|
Workflow Status
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}>
|
||||||
|
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0 flex-1 min-h-0">
|
<CardContent className={cn("p-0 flex-1 min-h-0 relative transition-opacity duration-200", isSidebarCollapsed ? "opacity-0 pointer-events-none" : "opacity-100")}>
|
||||||
|
<div className="absolute inset-0">
|
||||||
<WorkflowVisualizer />
|
<WorkflowVisualizer />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Col: Detail Tabs (8 cols) */}
|
{/* Right Col: Detail Tabs */}
|
||||||
<div className="col-span-8 h-full min-h-0">
|
<div className="flex-1 h-full min-h-0 overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||||
<div className="w-full bg-background border-b shrink-0">
|
<div className="w-full shrink-0">
|
||||||
<TabsList className="h-auto p-0 bg-transparent gap-0 flex-wrap justify-start w-full">
|
<TabsList className="h-auto p-0 bg-transparent gap-1 flex-wrap justify-start w-full border-b">
|
||||||
<TabsTrigger value="overview" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3">
|
<TabsTrigger
|
||||||
|
value="overview"
|
||||||
|
className="
|
||||||
|
rounded-t-md rounded-b-none
|
||||||
|
border border-b-0 border-border/50
|
||||||
|
bg-muted/60
|
||||||
|
data-[state=active]:bg-background
|
||||||
|
data-[state=active]:border-border
|
||||||
|
data-[state=active]:border-b-background
|
||||||
|
data-[state=active]:mb-[-1px]
|
||||||
|
data-[state=active]:shadow-sm
|
||||||
|
data-[state=active]:z-10
|
||||||
|
px-4 py-2.5
|
||||||
|
text-muted-foreground
|
||||||
|
data-[state=active]:text-foreground
|
||||||
|
relative
|
||||||
|
"
|
||||||
|
>
|
||||||
Overview
|
Overview
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="data" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3">
|
<TabsTrigger
|
||||||
|
value="data"
|
||||||
|
className="
|
||||||
|
rounded-t-md rounded-b-none
|
||||||
|
border border-b-0 border-border/50
|
||||||
|
bg-muted/60
|
||||||
|
data-[state=active]:bg-background
|
||||||
|
data-[state=active]:border-border
|
||||||
|
data-[state=active]:border-b-background
|
||||||
|
data-[state=active]:mb-[-1px]
|
||||||
|
data-[state=active]:shadow-sm
|
||||||
|
data-[state=active]:z-10
|
||||||
|
px-4 py-2.5
|
||||||
|
text-muted-foreground
|
||||||
|
data-[state=active]:text-foreground
|
||||||
|
relative
|
||||||
|
"
|
||||||
|
>
|
||||||
Fundamental Data
|
Fundamental Data
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{tabNodes.map(node => (
|
{tabNodes.map(node => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={node.id}
|
key={node.id}
|
||||||
value={node.id}
|
value={node.id}
|
||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3 gap-2 min-w-fit"
|
className="
|
||||||
|
rounded-t-md rounded-b-none
|
||||||
|
border border-b-0 border-border/50
|
||||||
|
bg-muted/60
|
||||||
|
data-[state=active]:bg-background
|
||||||
|
data-[state=active]:border-border
|
||||||
|
data-[state=active]:border-b-background
|
||||||
|
data-[state=active]:mb-[-1px]
|
||||||
|
data-[state=active]:shadow-sm
|
||||||
|
data-[state=active]:z-10
|
||||||
|
px-4 py-2.5 gap-2
|
||||||
|
text-muted-foreground
|
||||||
|
data-[state=active]:text-foreground
|
||||||
|
relative
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{node.name}
|
{formatNodeName(node.name)}
|
||||||
<TaskStatusIndicator status={tasks[node.id]?.status || schemas.TaskStatus.enum.Pending} />
|
<TaskStatusIndicator status={tasks[node.id]?.status || schemas.TaskStatus.enum.Pending} />
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
@ -131,7 +195,7 @@ export function ReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<div className="flex-1 min-h-0 bg-muted/5 relative">
|
<div className="flex-1 min-h-0 bg-background border border-t-0 rounded-b-md relative shadow-sm">
|
||||||
<TabsContent value="overview" className="absolute inset-0 m-0 p-6 overflow-y-auto">
|
<TabsContent value="overview" className="absolute inset-0 m-0 p-6 overflow-y-auto">
|
||||||
<OverviewTabContent
|
<OverviewTabContent
|
||||||
status={status}
|
status={status}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user