394 lines
12 KiB
JavaScript
394 lines
12 KiB
JavaScript
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
|
||
// 执行步骤显示组件
|
||
|
||
import React, { useState } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
Accordion,
|
||
AccordionItem,
|
||
AccordionButton,
|
||
AccordionPanel,
|
||
AccordionIcon,
|
||
Card,
|
||
CardBody,
|
||
Badge,
|
||
HStack,
|
||
VStack,
|
||
Flex,
|
||
Text,
|
||
Box,
|
||
Code,
|
||
IconButton,
|
||
Tooltip,
|
||
Collapse,
|
||
} from '@chakra-ui/react';
|
||
import { Activity, ChevronDown, ChevronRight, Copy, Check, Database, FileJson } from 'lucide-react';
|
||
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
|
||
|
||
/**
|
||
* 格式化结果数据用于显示
|
||
*/
|
||
const formatResultData = (data) => {
|
||
if (data === null || data === undefined) return null;
|
||
if (typeof data === 'string') return data;
|
||
try {
|
||
return JSON.stringify(data, null, 2);
|
||
} catch {
|
||
return String(data);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取结果数据的预览文本
|
||
*/
|
||
const getResultPreview = (result) => {
|
||
if (!result) return '无数据';
|
||
|
||
// 如果有 data 字段
|
||
if (result.data) {
|
||
const data = result.data;
|
||
// 检查常见的数据结构
|
||
if (data.chart_data) {
|
||
return `图表数据: ${data.chart_data.labels?.length || 0} 项`;
|
||
}
|
||
if (data.sector_data) {
|
||
const sectorCount = Object.keys(data.sector_data).length;
|
||
return `${sectorCount} 个板块分析`;
|
||
}
|
||
if (data.stocks) {
|
||
return `${data.stocks.length} 只股票`;
|
||
}
|
||
if (Array.isArray(data)) {
|
||
return `${data.length} 条记录`;
|
||
}
|
||
if (data.date || data.formatted_date) {
|
||
return `日期: ${data.formatted_date || data.date}`;
|
||
}
|
||
}
|
||
|
||
// 如果结果本身是数组
|
||
if (Array.isArray(result)) {
|
||
return `${result.length} 条记录`;
|
||
}
|
||
|
||
// 如果是对象,返回键数量
|
||
if (typeof result === 'object') {
|
||
const keys = Object.keys(result);
|
||
return `${keys.length} 个字段`;
|
||
}
|
||
|
||
return '查看详情';
|
||
};
|
||
|
||
/**
|
||
* 单个步骤卡片组件
|
||
*/
|
||
const StepCard = ({ result, idx }) => {
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const hasResult = result.result && (
|
||
typeof result.result === 'object'
|
||
? Object.keys(result.result).length > 0
|
||
: result.result
|
||
);
|
||
|
||
const handleCopy = async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await navigator.clipboard.writeText(formatResultData(result.result));
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
} catch (err) {
|
||
console.error('复制失败:', err);
|
||
}
|
||
};
|
||
|
||
// 渲染结果数据
|
||
const renderResultData = () => {
|
||
if (!result.result) return null;
|
||
|
||
const data = result.result;
|
||
|
||
// 如果有 echarts 图表数据,尝试生成图表
|
||
if (data.data?.chart_data) {
|
||
const chartData = data.data.chart_data;
|
||
|
||
// 验证图表数据是否有效
|
||
const hasValidChartData = chartData.labels?.length > 0 && chartData.counts?.length > 0;
|
||
|
||
return (
|
||
<Box mt={3}>
|
||
{hasValidChartData ? (
|
||
(() => {
|
||
const echartsConfig = {
|
||
title: { text: `${data.data.formatted_date || ''} 涨停概念分布` },
|
||
tooltip: { trigger: 'axis' },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: chartData.labels,
|
||
axisLabel: { rotate: 30, fontSize: 10 },
|
||
},
|
||
yAxis: { type: 'value' },
|
||
series: [
|
||
{
|
||
name: '涨停家数',
|
||
type: 'bar',
|
||
data: chartData.counts,
|
||
itemStyle: {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0, y: 0, x2: 0, y2: 1,
|
||
colorStops: [
|
||
{ offset: 0, color: '#ff7043' },
|
||
{ offset: 1, color: '#ff5722' },
|
||
],
|
||
},
|
||
},
|
||
},
|
||
],
|
||
};
|
||
|
||
const markdownContent = `\`\`\`echarts
|
||
${JSON.stringify(echartsConfig)}
|
||
\`\`\``;
|
||
|
||
return <MarkdownWithCharts content={markdownContent} variant="dark" />;
|
||
})()
|
||
) : (
|
||
<Text fontSize="xs" color="gray.500">暂无图表数据</Text>
|
||
)}
|
||
|
||
{/* 板块详情 */}
|
||
{data.data?.sector_data && (
|
||
<Box mt={3}>
|
||
<Text fontSize="xs" color="gray.400" mb={2}>
|
||
板块详情 ({Object.keys(data.data.sector_data).length} 个板块)
|
||
</Text>
|
||
<Box
|
||
maxH="300px"
|
||
overflowY="auto"
|
||
fontSize="xs"
|
||
sx={{
|
||
'&::-webkit-scrollbar': { width: '4px' },
|
||
'&::-webkit-scrollbar-track': { bg: 'transparent' },
|
||
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
|
||
}}
|
||
>
|
||
{Object.entries(data.data.sector_data).map(([sector, info]) => (
|
||
<Box
|
||
key={sector}
|
||
mb={2}
|
||
p={2}
|
||
bg="rgba(255, 255, 255, 0.02)"
|
||
borderRadius="md"
|
||
border="1px solid"
|
||
borderColor="rgba(255, 255, 255, 0.05)"
|
||
>
|
||
<HStack justify="space-between" mb={1}>
|
||
<Badge colorScheme="purple" fontSize="xs">{sector}</Badge>
|
||
<Text color="gray.500">{info.count} 只</Text>
|
||
</HStack>
|
||
{info.stocks?.slice(0, 3).map((stock, i) => (
|
||
<Box key={i} mt={1} pl={2} borderLeft="2px solid" borderColor="purple.500">
|
||
<Text color="gray.300" fontWeight="medium">
|
||
{stock.sname} ({stock.scode})
|
||
</Text>
|
||
{stock.brief && (
|
||
<Text color="gray.500" fontSize="xs" noOfLines={2}>
|
||
{stock.brief.replace(/<br>/g, ' ')}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
))}
|
||
{info.stocks?.length > 3 && (
|
||
<Text color="gray.600" fontSize="xs" mt={1}>
|
||
还有 {info.stocks.length - 3} 只...
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// 默认显示 JSON 数据
|
||
return (
|
||
<Box mt={3}>
|
||
<Code
|
||
display="block"
|
||
p={3}
|
||
borderRadius="md"
|
||
fontSize="xs"
|
||
whiteSpace="pre-wrap"
|
||
bg="rgba(0, 0, 0, 0.3)"
|
||
color="gray.300"
|
||
maxH="300px"
|
||
overflowY="auto"
|
||
sx={{
|
||
'&::-webkit-scrollbar': { width: '4px' },
|
||
'&::-webkit-scrollbar-track': { bg: 'transparent' },
|
||
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
|
||
}}
|
||
>
|
||
{formatResultData(data)}
|
||
</Code>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, x: -10 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ delay: idx * 0.05 }}
|
||
>
|
||
<Card
|
||
bg="rgba(255, 255, 255, 0.03)"
|
||
backdropFilter="blur(10px)"
|
||
border="1px solid"
|
||
borderColor={isExpanded ? 'rgba(192, 132, 252, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
|
||
transition="all 0.2s"
|
||
_hover={{
|
||
borderColor: 'rgba(192, 132, 252, 0.2)',
|
||
}}
|
||
>
|
||
<CardBody p={3}>
|
||
{/* 步骤头部 - 可点击展开 */}
|
||
<Flex
|
||
align="center"
|
||
justify="space-between"
|
||
gap={2}
|
||
cursor={hasResult ? 'pointer' : 'default'}
|
||
onClick={() => hasResult && setIsExpanded(!isExpanded)}
|
||
>
|
||
<HStack flex={1} spacing={2}>
|
||
{hasResult && (
|
||
<Box color="gray.500" transition="transform 0.2s" transform={isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}>
|
||
<ChevronRight className="w-3 h-3" />
|
||
</Box>
|
||
)}
|
||
<Text fontSize="xs" fontWeight="medium" color="gray.300">
|
||
步骤 {idx + 1}: {result.tool_name || result.tool}
|
||
</Text>
|
||
</HStack>
|
||
|
||
<HStack spacing={2}>
|
||
{hasResult && (
|
||
<Tooltip label={copied ? '已复制' : '复制数据'} placement="top">
|
||
<IconButton
|
||
size="xs"
|
||
variant="ghost"
|
||
icon={copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||
onClick={handleCopy}
|
||
color={copied ? 'green.400' : 'gray.500'}
|
||
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
|
||
aria-label="复制"
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
<Badge
|
||
bgGradient={
|
||
result.status === 'success'
|
||
? 'linear(to-r, green.500, teal.500)'
|
||
: 'linear(to-r, red.500, orange.500)'
|
||
}
|
||
color="white"
|
||
variant="subtle"
|
||
boxShadow={
|
||
result.status === 'success'
|
||
? '0 2px 8px rgba(16, 185, 129, 0.3)'
|
||
: '0 2px 8px rgba(239, 68, 68, 0.3)'
|
||
}
|
||
>
|
||
{result.status}
|
||
</Badge>
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 步骤元信息 */}
|
||
<HStack mt={1} spacing={3} fontSize="xs" color="gray.500">
|
||
{result.execution_time && (
|
||
<Text>⏱️ {result.execution_time.toFixed(2)}s</Text>
|
||
)}
|
||
{hasResult && (
|
||
<HStack spacing={1}>
|
||
<Database className="w-3 h-3" />
|
||
<Text>{getResultPreview(result.result)}</Text>
|
||
</HStack>
|
||
)}
|
||
</HStack>
|
||
|
||
{/* 错误信息 */}
|
||
{result.error && (
|
||
<Text fontSize="xs" color="red.400" mt={1}>
|
||
⚠️ {result.error}
|
||
</Text>
|
||
)}
|
||
|
||
{/* 展开的详细数据 */}
|
||
<Collapse in={isExpanded} animateOpacity>
|
||
{isExpanded && renderResultData()}
|
||
</Collapse>
|
||
</CardBody>
|
||
</Card>
|
||
</motion.div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* ExecutionStepsDisplay - 执行步骤显示组件
|
||
*
|
||
* @param {Object} props
|
||
* @param {Array} props.steps - 执行步骤列表
|
||
* @param {Object} props.plan - 执行计划(可选)
|
||
* @returns {JSX.Element}
|
||
*/
|
||
const ExecutionStepsDisplay = ({ steps, plan }) => {
|
||
return (
|
||
<Accordion allowToggle>
|
||
<AccordionItem
|
||
border="1px solid"
|
||
borderColor="rgba(255, 255, 255, 0.1)"
|
||
borderRadius="lg"
|
||
bg="rgba(255, 255, 255, 0.03)"
|
||
backdropFilter="blur(10px)"
|
||
_hover={{
|
||
bg: 'rgba(255, 255, 255, 0.05)',
|
||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||
}}
|
||
>
|
||
<AccordionButton px={4} py={2}>
|
||
<HStack flex={1} spacing={2}>
|
||
<Activity className="w-4 h-4" color="#C084FC" />
|
||
<Text color="gray.300" fontSize="sm">
|
||
执行详情
|
||
</Text>
|
||
<Badge
|
||
bgGradient="linear(to-r, purple.500, pink.500)"
|
||
color="white"
|
||
variant="subtle"
|
||
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
|
||
>
|
||
{steps.length} 步骤
|
||
</Badge>
|
||
</HStack>
|
||
<AccordionIcon color="gray.400" />
|
||
</AccordionButton>
|
||
<AccordionPanel pb={4}>
|
||
<VStack spacing={2} align="stretch">
|
||
{steps.map((result, idx) => (
|
||
<StepCard key={idx} result={result} idx={idx} />
|
||
))}
|
||
</VStack>
|
||
</AccordionPanel>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
);
|
||
};
|
||
|
||
export default ExecutionStepsDisplay;
|