Files
vf_react/src/views/Community/components/MidjourneyHeroSection.js

814 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useRef, useMemo, useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Particles from 'react-tsparticles';
import { loadSlim } from 'tsparticles-slim';
import {
Box,
Container,
Heading,
Text,
Button,
HStack,
VStack,
Badge,
Grid,
GridItem,
Stat,
StatLabel,
StatNumber,
Flex,
Tag,
useColorModeValue,
} from '@chakra-ui/react';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ComposedChart,
ReferenceLine,
ReferenceDot,
Cell,
} from 'recharts';
import { indexService } from '../../../services/eventService';
// 将后端分钟/分时数据转换为 Recharts 数据
const toLineSeries = (resp) => {
const arr = resp?.data || [];
return arr.map((d, i) => ({ time: d.time || i, value: d.price ?? d.close, volume: d.volume }));
};
// 提取昨日收盘价:优先使用最后一条记录的 prev_close否则回退到倒数第二条的 close
const getPrevClose = (resp) => {
const arr = resp?.data || [];
if (!arr.length) return null;
const last = arr[arr.length - 1] || {};
if (last.prev_close !== undefined && last.prev_close !== null && isFinite(Number(last.prev_close))) {
return Number(last.prev_close);
}
const idx = arr.length >= 2 ? arr.length - 2 : arr.length - 1;
const k = arr[idx] || {};
const candidate = k.close ?? k.c ?? k.price ?? null;
return candidate != null ? Number(candidate) : null;
};
// 组合图表组件(折线图 + 成交量柱状图)
const CombinedChart = ({ series, title, color = "#FFD700", basePrice = null }) => {
const [cursorIndex, setCursorIndex] = useState(0);
const cursorRef = useRef(0);
// 直接将光标设置到最后一个数据点,不再使用动画
useEffect(() => {
if (!series || series.length === 0) return;
// 直接设置到最后一个点
const lastIndex = series.length - 1;
cursorRef.current = lastIndex;
setCursorIndex(lastIndex);
}, [series && series.length]);
const yDomain = useMemo(() => {
if (!series || series.length === 0) return ['auto', 'auto'];
const values = series
.map((d) => d?.value)
.filter((v) => typeof v === 'number' && isFinite(v));
if (values.length === 0) return ['auto', 'auto'];
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const maxAbs = Math.max(Math.abs(minVal), Math.abs(maxVal));
const padding = Math.max(maxAbs * 0.1, 0.2);
return [-maxAbs - padding, maxAbs + padding];
}, [series]);
// 当前高亮点
const activePoint = useMemo(() => {
if (!series || series.length === 0) return null;
if (cursorIndex < 0 || cursorIndex >= series.length) return null;
return series[cursorIndex];
}, [series, cursorIndex]);
// 稳定的X轴ticks避免随渲染跳动而闪烁
const xTicks = useMemo(() => {
if (!series || series.length === 0) return [];
const desiredLabels = ['09:30', '10:30', '11:30', '14:00', '15:00'];
const set = new Set(series.map(d => d?.time));
let ticks = desiredLabels.filter(t => set.has(t));
if (ticks.length === 0) {
// 回退到首/中/尾的稳定采样,避免空白
const len = series.length;
const idxs = [0, Math.round(len * 0.25), Math.round(len * 0.5), Math.round(len * 0.75), len - 1];
ticks = idxs.map(i => series[i]?.time).filter(Boolean);
}
return ticks;
}, [series && series.length]);
return (
<Box h="full" position="relative">
<Text
fontSize="xs"
color={color}
fontFamily="monospace"
mb={1}
px={2}
>
{title}
</Text>
<ResponsiveContainer width="100%" height="90%">
<ComposedChart data={series} margin={{ top: 10, right: 10, left: 0, bottom: 30 }}>
<defs>
<linearGradient id={`gradient-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.8}/>
<stop offset="100%" stopColor={color} stopOpacity={0.2}/>
</linearGradient>
<linearGradient id={`barGradient-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3}/>
<stop offset="100%" stopColor={color} stopOpacity={0.05}/>
</linearGradient>
<linearGradient id={`barGradientActive-${title.replace(/[.\s]/g, '')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.8}/>
<stop offset="100%" stopColor={color} stopOpacity={0.3}/>
</linearGradient>
{/* 发光效果 */}
<filter id={`glow-${title.replace(/[.\s]/g, '')}`}>
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255, 215, 0, 0.1)" />
<XAxis
dataKey="time"
stroke={color}
tick={{ fill: color, fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: `${color}33` }}
ticks={xTicks}
interval={0}
allowDuplicatedCategory={false}
/>
{/* 左Y轴 - 价格 */}
<YAxis
yAxisId="price"
stroke={color}
domain={yDomain}
tickFormatter={(v) => `${v.toFixed(2)}%`}
orientation="left"
/>
{/* 右Y轴 - 成交量(隐藏) */}
<YAxis
yAxisId="volume"
orientation="right"
hide
domain={[0, 'dataMax + 1000']}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(0,0,0,0.9)',
border: `1px solid ${color}`,
borderRadius: '8px'
}}
labelStyle={{ color: '#fff' }}
itemStyle={{ color: '#fff' }}
labelFormatter={(label) => `时间: ${label}`}
formatter={(value, name) => {
if (name === 'value') {
const pct = Number(value);
if (typeof basePrice === 'number' && isFinite(basePrice)) {
const price = basePrice * (1 + pct / 100);
return [price.toFixed(2), '价格'];
}
return [`${pct.toFixed(2)}%`, '涨跌幅'];
}
if (name === 'volume') return [`${(Number(value) / 100000000).toFixed(2)}亿`, '成交量'];
return [value, name];
}}
/>
{/* 零轴参考线 */}
<ReferenceLine yAxisId="price" y={0} stroke="#666" strokeDasharray="4 4" />
{/* 成交量柱状图 */}
<Bar
yAxisId="volume"
dataKey="volume"
fill={`url(#barGradient-${title.replace(/[.\s]/g, '')})`}
radius={[2, 2, 0, 0]}
isAnimationActive={false}
barSize={20}
>
{series.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={index <= cursorIndex ? `url(#barGradientActive-${title.replace(/[.\s]/g, '')})` : `url(#barGradient-${title.replace(/[.\s]/g, '')})`}
/>
))}
</Bar>
{/* 价格折线 */}
<Line
yAxisId="price"
isAnimationActive={false}
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
dot={false}
/>
{/* 移动的亮点 - 使用 ReferenceDot 贴合主数据坐标系 */}
{activePoint && (
<ReferenceDot
xAxisId={0}
yAxisId="price"
x={activePoint.time}
y={activePoint.value}
r={6}
isFront
ifOverflow="hidden"
shape={(props) => (
<g>
<circle
cx={props.cx}
cy={props.cy}
r={8}
fill={color}
stroke="#fff"
strokeWidth={2}
filter={`url(#glow-${title.replace(/[.\s]/g, '')})`}
/>
</g>
)}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</Box>
);
};
// 数据流动线条组件
function DataStreams() {
const lines = useMemo(() => {
return [...Array(15)].map((_, i) => ({
id: i,
startX: Math.random() * 100,
delay: Math.random() * 5,
duration: 3 + Math.random() * 2,
height: 30 + Math.random() * 70
}));
}, []);
return (
<Box position="absolute" inset={0} overflow="hidden" pointerEvents="none">
{lines.map((line) => (
<motion.div
key={line.id}
style={{
position: 'absolute',
width: '1px',
background: 'linear-gradient(to bottom, transparent, rgba(255, 215, 0, 0.3), transparent)',
left: `${line.startX}%`,
height: `${line.height}%`,
}}
initial={{ y: '-100%', opacity: 0 }}
animate={{
y: '200%',
opacity: [0, 0.5, 0.5, 0]
}}
transition={{
duration: line.duration,
delay: line.delay,
repeat: Infinity,
ease: "linear"
}}
/>
))}
</Box>
);
}
// 主组件
export default function MidjourneyHeroSection() {
const [sse, setSse] = useState({
sh: { data: [], base: null },
sz: { data: [], base: null },
cyb: { data: [], base: null }
});
useEffect(() => {
const fetchData = async () => {
try {
const [shTL, szTL, cybTL, shDaily, szDaily, cybDaily] = await Promise.all([
// 指数不传 event_time后端自动返回"最新可用"交易日
indexService.getKlineData('000001.SH', 'timeline'),
indexService.getKlineData('399001.SZ', 'timeline'),
indexService.getKlineData('399006.SZ', 'timeline'), // 创业板指
indexService.getKlineData('000001.SH', 'daily'),
indexService.getKlineData('399001.SZ', 'daily'),
indexService.getKlineData('399006.SZ', 'daily'),
]);
const shPrevClose = getPrevClose(shDaily);
const szPrevClose = getPrevClose(szDaily);
const cybPrevClose = getPrevClose(cybDaily);
const shSeries = toLineSeries(shTL);
const szSeries = toLineSeries(szTL);
const cybSeries = toLineSeries(cybTL);
const baseSh = (typeof shPrevClose === 'number' && isFinite(shPrevClose))
? shPrevClose
: (shSeries.length ? shSeries[0].value : 1);
const baseSz = (typeof szPrevClose === 'number' && isFinite(szPrevClose))
? szPrevClose
: (szSeries.length ? szSeries[0].value : 1);
const baseCyb = (typeof cybPrevClose === 'number' && isFinite(cybPrevClose))
? cybPrevClose
: (cybSeries.length ? cybSeries[0].value : 1);
const shPct = shSeries.map(p => ({
time: p.time,
value: ((p.value / baseSh) - 1) * 100,
volume: p.volume || 0
}));
const szPct = szSeries.map(p => ({
time: p.time,
value: ((p.value / baseSz) - 1) * 100,
volume: p.volume || 0
}));
const cybPct = cybSeries.map(p => ({
time: p.time,
value: ((p.value / baseCyb) - 1) * 100,
volume: p.volume || 0
}));
setSse({
sh: { data: shPct, base: baseSh },
sz: { data: szPct, base: baseSz },
cyb: { data: cybPct, base: baseCyb }
});
} catch (e) {
// ignore
}
};
fetchData();
}, []);
const particlesInit = async (engine) => {
await loadSlim(engine);
};
const particlesOptions = {
particles: {
number: {
value: 80,
density: {
enable: true,
value_area: 800
}
},
color: {
value: ["#FFD700", "#FF9800", "#FFC107", "#FFEB3B"]
},
shape: {
type: "circle"
},
opacity: {
value: 0.3,
random: true,
anim: {
enable: true,
speed: 1,
opacity_min: 0.1,
sync: false
}
},
size: {
value: 2,
random: true,
anim: {
enable: true,
speed: 2,
size_min: 0.1,
sync: false
}
},
line_linked: {
enable: true,
distance: 150,
color: "#FFD700",
opacity: 0.2,
width: 1
},
move: {
enable: true,
speed: 0.5,
direction: "none",
random: false,
straight: false,
out_mode: "out",
bounce: false,
}
},
interactivity: {
detect_on: "canvas",
events: {
onhover: {
enable: true,
mode: "grab"
},
onclick: {
enable: true,
mode: "push"
},
resize: true
},
modes: {
grab: {
distance: 140,
line_linked: {
opacity: 0.5
}
},
push: {
particles_nb: 4
}
}
},
retina_detect: true
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease: "easeOut"
}
}
};
return (
<Box
position="relative"
minH="100vh"
bg="linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #000000 100%)"
overflow="hidden"
pointerEvents="none"
>
{/* 粒子背景 */}
<Box position="absolute" inset={0} zIndex={-1} pointerEvents="none">
<Particles
id="tsparticles"
init={particlesInit}
options={particlesOptions}
style={{
position: 'absolute',
width: '100%',
height: '100%',
}}
/>
</Box>
{/* 数据流动效果 */}
<DataStreams />
{/* 内容容器 */}
<Container maxW="7xl" position="relative" zIndex={1} pt={20} pb={20}>
<Grid templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }} gap={12} alignItems="center" minH="70vh">
{/* 左侧文本内容 */}
<GridItem>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<VStack align="start" spacing={6}>
{/* 标签 */}
<motion.div variants={itemVariants}>
<Badge
colorScheme="yellow"
variant="subtle"
px={4}
py={2}
borderRadius="full"
fontSize="sm"
fontFamily="monospace"
display="inline-flex"
alignItems="center"
>
<Box
as="span"
w={2}
h={2}
bg="yellow.400"
borderRadius="full"
mr={2}
animation="pulse 2s ease-in-out infinite"
/>
AI-Assisted Curation
</Badge>
</motion.div>
{/* 主标题 */}
<motion.div variants={itemVariants}>
<Heading
fontSize={{ base: '4xl', md: '5xl', lg: '6xl' }}
fontWeight="bold"
lineHeight="shorter"
>
<Text
as="span"
bgGradient="linear(to-r, yellow.400, orange.400, yellow.500)"
bgClip="text"
>
ME-Agent
</Text>
<br />
<Text as="span" color="white">
实时分析系统
</Text>
</Heading>
</motion.div>
{/* 副标题 */}
<motion.div variants={itemVariants}>
<Heading
as="h3"
fontSize="xl"
color="gray.300"
fontWeight="semibold"
>
基于微调版{' '}
<Text as="span" color="yellow.400" fontFamily="monospace">
deepseek-r1
</Text>{' '}
进行深度研究
</Heading>
</motion.div>
{/* 描述文本 */}
<motion.div variants={itemVariants}>
<Text
color="gray.400"
fontSize="md"
lineHeight="tall"
maxW="xl"
>
ME (Money Edge) 是一款以大模型为底座由资深分析师参与校准的信息辅助系统
专为金融研究与企业决策等场景设计系统侧重于多源信息的汇聚清洗与结构化整理
结合自主训练的领域知识图谱并配合专家人工复核与整合帮助用户高效获取相关线索与参考资料
</Text>
</motion.div>
{/* 特性标签 */}
<motion.div variants={itemVariants}>
<HStack spacing={3} flexWrap="wrap">
{['海量信息整理', '领域知识图谱', '分析师复核', '结构化呈现'].map((tag) => (
<Tag
key={tag}
size="md"
variant="subtle"
colorScheme="gray"
borderRadius="lg"
px={3}
py={1}
bg="gray.800"
color="gray.300"
borderWidth="1px"
borderColor="gray.600"
>
{tag}
</Tag>
))}
</HStack>
</motion.div>
{/* 按钮组 */}
<motion.div variants={itemVariants}>
<HStack spacing={4} pt={4}>
<Button
size="lg"
variant="outline"
color="gray.300"
borderColor="gray.600"
borderRadius="full"
px={8}
_hover={{
bg: "gray.800",
borderColor: "gray.500",
}}
transition="all 0.2s"
>
了解更多
</Button>
</HStack>
</motion.div>
{/* 统计数据 */}
<motion.div variants={itemVariants}>
<Grid
templateColumns="repeat(3, 1fr)"
gap={6}
pt={8}
borderTop="1px"
borderTopColor="gray.800"
w="full"
>
{[
{ label: '数据源', value: '10K+' },
{ label: '日处理', value: '1M+' },
{ label: '准确率', value: '98%' }
].map((stat) => (
<Stat key={stat.label}>
<StatNumber
fontSize="2xl"
fontWeight="bold"
color="yellow.400"
fontFamily="monospace"
>
{stat.value}
</StatNumber>
<StatLabel fontSize="sm" color="gray.500">
{stat.label}
</StatLabel>
</Stat>
))}
</Grid>
</motion.div>
</VStack>
</motion.div>
</GridItem>
{/* 右侧金融图表可视化 */}
<GridItem>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1, delay: 0.5 }}
>
<Box position="relative" h={{ base: '400px', md: '500px', lg: '600px' }}>
{/* 图表网格布局 */}
<Grid
templateColumns="repeat(2, 1fr)"
templateRows="repeat(2, 1fr)"
gap={4}
h="full"
p={4}
bg="rgba(0, 0, 0, 0.3)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 215, 0, 0.2)"
backdropFilter="blur(10px)"
>
{/* 上证指数 */}
<GridItem colSpan={2}>
<Box
h="full"
bg="rgba(0, 0, 0, 0.4)"
borderRadius="lg"
p={2}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.1)"
>
<CombinedChart
series={sse.sh?.data || []}
basePrice={sse.sh?.base}
title="000001.SH 上证指数"
color="#FFD700"
/>
</Box>
</GridItem>
{/* 深证成指 */}
<GridItem>
<Box
h="full"
bg="rgba(0, 0, 0, 0.4)"
borderRadius="lg"
p={2}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.1)"
>
<CombinedChart
series={sse.sz?.data || []}
basePrice={sse.sz?.base}
title="399001.SZ 深证成指"
color="#00E0FF"
/>
</Box>
</GridItem>
{/* 创业板指 */}
<GridItem>
<Box
h="full"
bg="rgba(0, 0, 0, 0.4)"
borderRadius="lg"
p={2}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.1)"
>
<CombinedChart
series={sse.cyb?.data || []}
basePrice={sse.cyb?.base}
title="399006.SZ 创业板指"
color="#FF69B4"
/>
</Box>
</GridItem>
</Grid>
{/* 装饰性光效 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="150%"
h="150%"
pointerEvents="none"
>
<Box
position="absolute"
top="20%"
left="20%"
w="200px"
h="200px"
bg="radial-gradient(circle, rgba(255, 215, 0, 0.15), transparent)"
borderRadius="full"
filter="blur(40px)"
animation="pulse 4s ease-in-out infinite"
/>
<Box
position="absolute"
bottom="20%"
right="20%"
w="150px"
h="150px"
bg="radial-gradient(circle, rgba(255, 152, 0, 0.15), transparent)"
borderRadius="full"
filter="blur(40px)"
animation="pulse 4s ease-in-out infinite"
sx={{ animationDelay: '2s' }}
/>
</Box>
</Box>
</motion.div>
</GridItem>
</Grid>
</Container>
{/* 底部渐变遮罩 */}
<Box
position="absolute"
bottom={0}
left={0}
right={0}
h="128px"
bgGradient="linear(to-t, black, transparent)"
zIndex={-1}
/>
{/* 全局样式 */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
`}</style>
</Box>
);
}