update pay function
This commit is contained in:
@@ -12,13 +12,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Heading,
|
Heading,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
SimpleGrid,
|
|
||||||
Icon,
|
Icon,
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Tooltip,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { AlertCircle, Clock, TrendingUp } from 'lucide-react';
|
import { AlertCircle, Clock, TrendingUp, Info } from 'lucide-react';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
@@ -28,24 +26,14 @@ const animations = `
|
|||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
50% { opacity: 0.6; transform: scale(1.1); }
|
50% { opacity: 0.6; transform: scale(1.1); }
|
||||||
}
|
}
|
||||||
@keyframes float3d {
|
|
||||||
0%, 100% { transform: translateY(0) translateZ(0) rotateX(0deg); }
|
|
||||||
25% { transform: translateY(-8px) translateZ(20px) rotateX(5deg); }
|
|
||||||
50% { transform: translateY(-4px) translateZ(10px) rotateX(0deg); }
|
|
||||||
75% { transform: translateY(-12px) translateZ(30px) rotateX(-5deg); }
|
|
||||||
}
|
|
||||||
@keyframes glow {
|
|
||||||
0%, 100% { box-shadow: 0 0 5px currentColor, 0 0 10px currentColor; }
|
|
||||||
50% { box-shadow: 0 0 20px currentColor, 0 0 30px currentColor, 0 0 40px currentColor; }
|
|
||||||
}
|
|
||||||
@keyframes orbit {
|
|
||||||
0% { transform: rotate(0deg) translateX(120px) rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg) translateX(120px) rotate(-360deg); }
|
|
||||||
}
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: -200% 0; }
|
0% { background-position: -200% 0; }
|
||||||
100% { background-position: 200% 0; }
|
100% { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
@keyframes floatSlow {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 注入样式
|
// 注入样式
|
||||||
@@ -67,7 +55,6 @@ const fetchIndexKline = async (indexCode) => {
|
|||||||
const response = await fetch(`/api/index/${indexCode}/kline?type=daily`);
|
const response = await fetch(`/api/index/${indexCode}/kline?type=daily`);
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
logger.debug('HeroPanel', 'fetchIndexKline success', { indexCode, dataLength: data?.data?.length });
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('HeroPanel', 'fetchIndexKline error', { indexCode, error: error.message });
|
logger.error('HeroPanel', 'fetchIndexKline error', { indexCode, error: error.message });
|
||||||
@@ -89,7 +76,6 @@ const fetchPopularConcepts = async () => {
|
|||||||
if (data.results?.length > 0) {
|
if (data.results?.length > 0) {
|
||||||
return data.results.map(item => ({
|
return data.results.map(item => ({
|
||||||
name: item.concept,
|
name: item.concept,
|
||||||
value: Math.abs(item.price_info?.avg_change_pct || 1) + 5,
|
|
||||||
change_pct: item.price_info?.avg_change_pct || 0,
|
change_pct: item.price_info?.avg_change_pct || 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -110,18 +96,17 @@ const isInTradingTime = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 迷你K线图组件(保持原有样式)
|
* 紧凑型K线指数卡片
|
||||||
*/
|
*/
|
||||||
const MiniIndexChart = ({ indexCode, indexName }) => {
|
const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||||
const [chartData, setChartData] = useState(null);
|
const [chartData, setChartData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [latestData, setLatestData] = useState(null);
|
const [latestData, setLatestData] = useState(null);
|
||||||
const [currentDate, setCurrentDate] = useState('');
|
|
||||||
|
|
||||||
const upColor = '#ec0000';
|
const upColor = '#ec0000';
|
||||||
const downColor = '#00da3c';
|
const downColor = '#00da3c';
|
||||||
|
|
||||||
const loadDailyData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
const data = await fetchIndexKline(indexCode);
|
const data = await fetchIndexKline(indexCode);
|
||||||
if (data?.data?.length > 0) {
|
if (data?.data?.length > 0) {
|
||||||
const latest = data.data[data.data.length - 1];
|
const latest = data.data[data.data.length - 1];
|
||||||
@@ -131,8 +116,7 @@ const MiniIndexChart = ({ indexCode, indexName }) => {
|
|||||||
change: prevClose ? (((latest.close - prevClose) / prevClose) * 100).toFixed(2) : '0.00',
|
change: prevClose ? (((latest.close - prevClose) / prevClose) * 100).toFixed(2) : '0.00',
|
||||||
isPositive: latest.close >= prevClose
|
isPositive: latest.close >= prevClose
|
||||||
});
|
});
|
||||||
setCurrentDate(latest.time);
|
const recentData = data.data.slice(-40);
|
||||||
const recentData = data.data.slice(-60);
|
|
||||||
setChartData({
|
setChartData({
|
||||||
dates: recentData.map(item => item.time),
|
dates: recentData.map(item => item.time),
|
||||||
klineData: recentData.map(item => [item.open, item.close, item.low, item.high]),
|
klineData: recentData.map(item => [item.open, item.close, item.low, item.high]),
|
||||||
@@ -142,75 +126,32 @@ const MiniIndexChart = ({ indexCode, indexName }) => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [indexCode]);
|
}, [indexCode]);
|
||||||
|
|
||||||
const loadMinuteData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/index/${indexCode}/kline?type=minute`);
|
|
||||||
if (!response.ok) return;
|
|
||||||
const data = await response.json();
|
|
||||||
if (data?.data?.length > 0) {
|
|
||||||
const latest = data.data[data.data.length - 1];
|
|
||||||
const dayOpen = data.data[0].open;
|
|
||||||
setLatestData({
|
|
||||||
close: latest.close,
|
|
||||||
change: dayOpen ? (((latest.close - dayOpen) / dayOpen) * 100).toFixed(2) : '0.00',
|
|
||||||
isPositive: latest.close >= dayOpen
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('HeroPanel', 'loadMinuteData error', error);
|
|
||||||
}
|
|
||||||
}, [indexCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
loadData();
|
||||||
let intervalId = null;
|
}, [loadData]);
|
||||||
const init = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await loadDailyData();
|
|
||||||
if (isMounted && isInTradingTime()) await loadMinuteData();
|
|
||||||
if (isMounted) setLoading(false);
|
|
||||||
};
|
|
||||||
init();
|
|
||||||
if (isInTradingTime()) {
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
if (isInTradingTime()) loadMinuteData();
|
|
||||||
else if (intervalId) clearInterval(intervalId);
|
|
||||||
}, 60000);
|
|
||||||
}
|
|
||||||
return () => { isMounted = false; if (intervalId) clearInterval(intervalId); };
|
|
||||||
}, [indexCode, loadDailyData, loadMinuteData]);
|
|
||||||
|
|
||||||
const chartOption = useMemo(() => {
|
const chartOption = useMemo(() => {
|
||||||
if (!chartData) return {};
|
if (!chartData) return {};
|
||||||
return {
|
return {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
grid: { left: 10, right: 10, top: 5, bottom: 20, containLabel: false },
|
grid: { left: 5, right: 5, top: 5, bottom: 5, containLabel: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: { type: 'cross', lineStyle: { color: 'rgba(255, 215, 0, 0.5)', width: 1, type: 'dashed' } },
|
axisPointer: { type: 'cross', lineStyle: { color: 'rgba(255, 215, 0, 0.5)', width: 1, type: 'dashed' } },
|
||||||
backgroundColor: 'rgba(20, 20, 20, 0.95)',
|
backgroundColor: 'rgba(20, 20, 20, 0.95)',
|
||||||
borderColor: '#FFD700',
|
borderColor: '#FFD700',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
textStyle: { color: '#fff', fontSize: 11, fontFamily: 'monospace' },
|
textStyle: { color: '#fff', fontSize: 10, fontFamily: 'monospace' },
|
||||||
padding: [8, 12],
|
padding: [6, 10],
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
const idx = params[0].dataIndex;
|
const idx = params[0].dataIndex;
|
||||||
const raw = chartData.rawData[idx];
|
const raw = chartData.rawData[idx];
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
const prevClose = raw.prev_close || raw.open;
|
const prevClose = raw.prev_close || raw.open;
|
||||||
const change = raw.close - prevClose;
|
const changePct = prevClose ? (((raw.close - prevClose) / prevClose) * 100).toFixed(2) : '0.00';
|
||||||
const changePct = prevClose ? ((change / prevClose) * 100).toFixed(2) : '0.00';
|
|
||||||
const isUp = raw.close >= prevClose;
|
const isUp = raw.close >= prevClose;
|
||||||
const color = isUp ? '#ec0000' : '#00da3c';
|
const color = isUp ? '#ec0000' : '#00da3c';
|
||||||
const sign = isUp ? '+' : '';
|
return `<div style="font-size:11px"><b>${raw.time}</b><br/>收盘: ${raw.close.toFixed(2)}<br/><span style="color:${color}">${isUp ? '+' : ''}${changePct}%</span></div>`;
|
||||||
return `<div style="font-weight:bold;border-bottom:1px solid #FFD700;padding-bottom:6px;margin-bottom:8px;">📅 ${raw.time}</div>
|
|
||||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;line-height:1.8;">
|
|
||||||
<span style="color:#999">开盘</span><span style="font-weight:bold">${raw.open.toFixed(2)}</span>
|
|
||||||
<span style="color:#999">最高</span><span style="font-weight:bold;color:#ec0000">${raw.high.toFixed(2)}</span>
|
|
||||||
<span style="color:#999">最低</span><span style="font-weight:bold;color:#00da3c">${raw.low.toFixed(2)}</span>
|
|
||||||
<span style="color:#999">收盘</span><span style="font-weight:bold">${raw.close.toFixed(2)}</span>
|
|
||||||
<span style="color:#999">涨跌幅</span><span style="font-weight:bold;color:${color}">${sign}${changePct}%</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
xAxis: { type: 'category', data: chartData.dates, show: false },
|
xAxis: { type: 'category', data: chartData.dates, show: false },
|
||||||
@@ -219,76 +160,56 @@ const MiniIndexChart = ({ indexCode, indexName }) => {
|
|||||||
type: 'candlestick',
|
type: 'candlestick',
|
||||||
data: chartData.klineData,
|
data: chartData.klineData,
|
||||||
itemStyle: { color: upColor, color0: downColor, borderColor: upColor, borderColor0: downColor },
|
itemStyle: { color: upColor, color0: downColor, borderColor: upColor, borderColor0: downColor },
|
||||||
barWidth: '60%'
|
barWidth: '70%'
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [chartData, upColor, downColor]);
|
}, [chartData, upColor, downColor]);
|
||||||
|
|
||||||
if (loading) return <Center h="120px"><Spinner size="sm" color="gold" /></Center>;
|
if (loading) return <Center h="80px"><Spinner size="sm" color="gold" /></Center>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack spacing={2} align="stretch" h="140px">
|
<Flex align="center" gap={4} h="80px">
|
||||||
<HStack justify="space-between">
|
{/* 左侧数据 */}
|
||||||
<VStack align="start" spacing={0}>
|
<VStack align="start" spacing={0} minW="100px">
|
||||||
<Text fontSize="xs" color="whiteAlpha.700" fontWeight="medium">{indexName}</Text>
|
<Text fontSize="xs" color="whiteAlpha.600" fontWeight="medium">{indexName}</Text>
|
||||||
<Text fontSize="2xl" fontWeight="extrabold" color="white" textShadow="0 2px 4px rgba(0,0,0,0.3)">
|
<Text fontSize="xl" fontWeight="bold" color="white">
|
||||||
{latestData?.close.toFixed(2)}
|
{latestData?.close.toFixed(2)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="xs" color="whiteAlpha.500">📅 {currentDate}</Text>
|
<Text
|
||||||
</VStack>
|
fontSize="sm"
|
||||||
<VStack align="end" spacing={1}>
|
fontWeight="bold"
|
||||||
<Text
|
color={latestData?.isPositive ? '#ec0000' : '#00da3c'}
|
||||||
fontSize="lg"
|
>
|
||||||
fontWeight="extrabold"
|
{latestData?.isPositive ? '▲' : '▼'} {latestData?.isPositive ? '+' : ''}{latestData?.change}%
|
||||||
color={latestData?.isPositive ? '#ec0000' : '#00da3c'}
|
</Text>
|
||||||
textShadow={latestData?.isPositive ? '0 2px 8px rgba(236,0,0,0.4)' : '0 2px 8px rgba(0,218,60,0.4)'}
|
</VStack>
|
||||||
>
|
{/* 右侧K线图 */}
|
||||||
{latestData?.isPositive ? '↗' : '↘'} {latestData?.isPositive ? '+' : ''}{latestData?.change}%
|
<Box flex="1" h="70px">
|
||||||
</Text>
|
<ReactECharts option={chartOption} style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} />
|
||||||
{isInTradingTime() && (
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Box w="6px" h="6px" borderRadius="full" bg="green.400" animation="pulse 2s infinite" boxShadow="0 0 6px rgba(72,187,120,0.8)" />
|
|
||||||
<Text fontSize="xs" color="green.400" fontWeight="medium">实时更新</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
<Box flex="1" position="relative">
|
|
||||||
<ReactECharts option={chartOption} style={{ height: '90px', width: '100%' }} opts={{ renderer: 'canvas' }} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3D 球形概念云动画组件
|
* 概念漂浮动画 - 稀疏美观版
|
||||||
*/
|
*/
|
||||||
const Concept3DSphere = () => {
|
const ConceptFloat = () => {
|
||||||
const [concepts, setConcepts] = useState([]);
|
const [concepts, setConcepts] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const containerRef = useRef(null);
|
|
||||||
const [rotation, setRotation] = useState({ x: 0, y: 0 });
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const [hoveredIdx, setHoveredIdx] = useState(null);
|
const [hoveredIdx, setHoveredIdx] = useState(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const data = await fetchPopularConcepts();
|
const data = await fetchPopularConcepts();
|
||||||
setConcepts(data.slice(0, 30)); // 取前30个
|
// 只取前12个,更稀疏
|
||||||
|
setConcepts(data.slice(0, 12));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 自动旋转
|
|
||||||
useEffect(() => {
|
|
||||||
if (isHovered) return;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setRotation(prev => ({ x: prev.x, y: prev.y + 0.5 }));
|
|
||||||
}, 50);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isHovered]);
|
|
||||||
|
|
||||||
const getColor = (pct) => {
|
const getColor = (pct) => {
|
||||||
if (pct > 5) return '#ff1744';
|
if (pct > 5) return '#ff1744';
|
||||||
if (pct > 2) return '#ff5252';
|
if (pct > 2) return '#ff5252';
|
||||||
@@ -303,90 +224,65 @@ const Concept3DSphere = () => {
|
|||||||
window.open(`https://valuefrontier.cn/htmls/${name}.html`, '_blank');
|
window.open(`https://valuefrontier.cn/htmls/${name}.html`, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Center h="200px"><Spinner size="md" color="gold" /></Center>;
|
if (loading) return <Center h="100%"><Spinner size="sm" color="gold" /></Center>;
|
||||||
|
|
||||||
// 计算球面坐标
|
// 预设12个位置,均匀分布且不重叠
|
||||||
const radius = 90;
|
const positions = [
|
||||||
const positions = concepts.map((_, i) => {
|
{ x: 15, y: 20 }, { x: 50, y: 15 }, { x: 85, y: 25 },
|
||||||
const phi = Math.acos(-1 + (2 * i) / concepts.length);
|
{ x: 25, y: 50 }, { x: 60, y: 45 }, { x: 80, y: 55 },
|
||||||
const theta = Math.sqrt(concepts.length * Math.PI) * phi;
|
{ x: 10, y: 75 }, { x: 40, y: 80 }, { x: 70, y: 75 },
|
||||||
return {
|
{ x: 20, y: 35 }, { x: 55, y: 65 }, { x: 90, y: 40 },
|
||||||
x: radius * Math.cos(theta) * Math.sin(phi),
|
];
|
||||||
y: radius * Math.sin(theta) * Math.sin(phi),
|
|
||||||
z: radius * Math.cos(phi)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 应用旋转
|
|
||||||
const rotatedPositions = positions.map(pos => {
|
|
||||||
const cosY = Math.cos(rotation.y * Math.PI / 180);
|
|
||||||
const sinY = Math.sin(rotation.y * Math.PI / 180);
|
|
||||||
const x1 = pos.x * cosY - pos.z * sinY;
|
|
||||||
const z1 = pos.x * sinY + pos.z * cosY;
|
|
||||||
return { x: x1, y: pos.y, z: z1 };
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
position="relative"
|
position="relative"
|
||||||
h="220px"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
overflow="hidden"
|
||||||
onMouseLeave={() => { setIsHovered(false); setHoveredIdx(null); }}
|
|
||||||
style={{ perspective: '800px' }}
|
|
||||||
>
|
>
|
||||||
{/* 中心发光圆 */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="50%"
|
|
||||||
left="50%"
|
|
||||||
transform="translate(-50%, -50%)"
|
|
||||||
w="60px"
|
|
||||||
h="60px"
|
|
||||||
borderRadius="full"
|
|
||||||
bg="radial-gradient(circle, rgba(255,215,0,0.3) 0%, transparent 70%)"
|
|
||||||
animation="pulse 3s ease-in-out infinite"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 概念标签 */}
|
{/* 概念标签 */}
|
||||||
{concepts.map((concept, idx) => {
|
{concepts.map((concept, idx) => {
|
||||||
const pos = rotatedPositions[idx];
|
const pos = positions[idx] || { x: 50, y: 50 };
|
||||||
const scale = (pos.z + radius) / (2 * radius); // 0-1, 越近越大
|
|
||||||
const opacity = 0.3 + scale * 0.7;
|
|
||||||
const size = 10 + scale * 6;
|
|
||||||
const zIndex = Math.round(scale * 100);
|
|
||||||
const color = getColor(concept.change_pct);
|
const color = getColor(concept.change_pct);
|
||||||
const isActive = hoveredIdx === idx;
|
const isActive = hoveredIdx === idx;
|
||||||
|
const size = 11 + (idx % 3) * 2; // 11-15px
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={idx}
|
key={idx}
|
||||||
position="absolute"
|
position="absolute"
|
||||||
left={`calc(50% + ${pos.x}px)`}
|
left={`${pos.x}%`}
|
||||||
top={`calc(50% + ${pos.y}px)`}
|
top={`${pos.y}%`}
|
||||||
transform={`translate(-50%, -50%) scale(${isActive ? 1.3 : 0.8 + scale * 0.4})`}
|
transform={`translate(-50%, -50%) scale(${isActive ? 1.3 : 1})`}
|
||||||
opacity={isActive ? 1 : opacity}
|
opacity={isActive ? 1 : 0.85}
|
||||||
zIndex={isActive ? 200 : zIndex}
|
zIndex={isActive ? 100 : 10}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
transition="all 0.2s ease"
|
transition="all 0.3s ease"
|
||||||
onMouseEnter={() => setHoveredIdx(idx)}
|
onMouseEnter={() => setHoveredIdx(idx)}
|
||||||
onMouseLeave={() => setHoveredIdx(null)}
|
onMouseLeave={() => setHoveredIdx(null)}
|
||||||
onClick={() => handleClick(concept.name)}
|
onClick={() => handleClick(concept.name)}
|
||||||
|
animation={`floatSlow ${5 + (idx % 3)}s ease-in-out infinite ${idx * 0.3}s`}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
fontSize={`${size}px`}
|
fontSize={`${size}px`}
|
||||||
fontWeight={isActive ? 'bold' : 'medium'}
|
fontWeight={isActive ? 'bold' : 'medium'}
|
||||||
color={color}
|
color={color}
|
||||||
textShadow={isActive ? `0 0 20px ${color}, 0 0 40px ${color}` : `0 0 8px ${color}50`}
|
textShadow={isActive
|
||||||
|
? `0 0 15px ${color}, 0 0 30px ${color}50`
|
||||||
|
: `0 0 8px ${color}40`
|
||||||
|
}
|
||||||
whiteSpace="nowrap"
|
whiteSpace="nowrap"
|
||||||
>
|
>
|
||||||
{concept.name}
|
{concept.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* 悬停提示 */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top="-30px"
|
top="-28px"
|
||||||
left="50%"
|
left="50%"
|
||||||
transform="translateX(-50%)"
|
transform="translateX(-50%)"
|
||||||
bg="rgba(0,0,0,0.9)"
|
bg="rgba(0,0,0,0.9)"
|
||||||
@@ -405,116 +301,126 @@ const Concept3DSphere = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 轨道环 */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="50%"
|
|
||||||
left="50%"
|
|
||||||
transform="translate(-50%, -50%) rotateX(70deg)"
|
|
||||||
w="180px"
|
|
||||||
h="180px"
|
|
||||||
borderRadius="full"
|
|
||||||
border="1px solid rgba(255,215,0,0.15)"
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="50%"
|
|
||||||
left="50%"
|
|
||||||
transform="translate(-50%, -50%) rotateX(70deg) rotateZ(60deg)"
|
|
||||||
w="160px"
|
|
||||||
h="160px"
|
|
||||||
borderRadius="full"
|
|
||||||
border="1px solid rgba(255,215,0,0.1)"
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用说明卡片组件
|
* 极简提示标签 - 悬停显示详情
|
||||||
*/
|
*/
|
||||||
const InfoCard = () => {
|
const InfoTooltip = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box position="relative" display="inline-block">
|
||||||
p={4}
|
{/* 触发器:小标签 */}
|
||||||
bg="linear-gradient(135deg, rgba(255,215,0,0.1) 0%, rgba(255,165,0,0.05) 100%)"
|
<HStack
|
||||||
borderRadius="xl"
|
spacing={1.5}
|
||||||
border="1px solid rgba(255,215,0,0.3)"
|
px={2.5}
|
||||||
position="relative"
|
py={1}
|
||||||
overflow="hidden"
|
bg="rgba(255,215,0,0.08)"
|
||||||
_before={{
|
border="1px solid rgba(255,215,0,0.2)"
|
||||||
content: '""',
|
borderRadius="full"
|
||||||
position: 'absolute',
|
cursor="pointer"
|
||||||
top: 0,
|
transition="all 0.2s"
|
||||||
left: '-100%',
|
_hover={{
|
||||||
width: '200%',
|
bg: 'rgba(255,215,0,0.15)',
|
||||||
height: '100%',
|
borderColor: 'rgba(255,215,0,0.4)',
|
||||||
background: 'linear-gradient(90deg, transparent, rgba(255,215,0,0.1), transparent)',
|
}}
|
||||||
animation: 'shimmer 3s linear infinite',
|
onMouseEnter={() => setIsOpen(true)}
|
||||||
}}
|
onMouseLeave={() => setIsOpen(false)}
|
||||||
>
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
<VStack align="start" spacing={3}>
|
>
|
||||||
{/* 标题 */}
|
<Icon as={Info} color="gold" boxSize={3} />
|
||||||
<HStack spacing={2}>
|
<Text fontSize="xs" color="gold" fontWeight="medium">
|
||||||
<Box
|
使用说明
|
||||||
p={1.5}
|
</Text>
|
||||||
borderRadius="lg"
|
</HStack>
|
||||||
bg="rgba(255,215,0,0.2)"
|
|
||||||
>
|
|
||||||
<Icon as={AlertCircle} color="gold" boxSize={4} />
|
|
||||||
</Box>
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight="bold"
|
|
||||||
bgGradient="linear(to-r, #FFD700, #FFA500)"
|
|
||||||
bgClip="text"
|
|
||||||
>
|
|
||||||
使用说明
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 评级说明 */}
|
{/* 悬浮提示框 */}
|
||||||
<HStack align="start" spacing={2}>
|
<Box
|
||||||
<Box w="3px" h="3px" mt="8px" borderRadius="full" bg="gold" />
|
position="absolute"
|
||||||
<Text fontSize="xs" color="whiteAlpha.800" lineHeight="1.8">
|
top="calc(100% + 8px)"
|
||||||
<Text as="span" color="gold" fontWeight="bold">重要度 SABC</Text>
|
left="0"
|
||||||
{" "}由大模型基于事件影响力评估,与收益率预测无关
|
zIndex={1000}
|
||||||
</Text>
|
minW="320px"
|
||||||
</HStack>
|
p={4}
|
||||||
|
bg="rgba(10,10,20,0.98)"
|
||||||
|
borderRadius="lg"
|
||||||
|
border="1px solid rgba(255,215,0,0.3)"
|
||||||
|
boxShadow="0 10px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,215,0,0.1)"
|
||||||
|
opacity={isOpen ? 1 : 0}
|
||||||
|
visibility={isOpen ? 'visible' : 'hidden'}
|
||||||
|
transform={isOpen ? 'translateY(0)' : 'translateY(-10px)'}
|
||||||
|
transition="all 0.2s ease"
|
||||||
|
onMouseEnter={() => setIsOpen(true)}
|
||||||
|
onMouseLeave={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{/* 小箭头 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="-6px"
|
||||||
|
left="20px"
|
||||||
|
w="12px"
|
||||||
|
h="12px"
|
||||||
|
bg="rgba(10,10,20,0.98)"
|
||||||
|
borderTop="1px solid rgba(255,215,0,0.3)"
|
||||||
|
borderLeft="1px solid rgba(255,215,0,0.3)"
|
||||||
|
transform="rotate(45deg)"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 筛选说明 */}
|
<VStack align="start" spacing={3}>
|
||||||
<HStack align="start" spacing={2}>
|
{/* SABC说明 */}
|
||||||
<Box w="3px" h="3px" mt="8px" borderRadius="full" bg="gold" />
|
<HStack spacing={2} align="start">
|
||||||
<Text fontSize="xs" color="whiteAlpha.800" lineHeight="1.8">
|
<Text color="gold" fontWeight="bold" fontSize="xs" minW="50px">SABC</Text>
|
||||||
事件包含
|
<Text fontSize="xs" color="whiteAlpha.800">
|
||||||
<Text as="span" color="#ec0000" fontWeight="bold"> 利好 </Text>
|
基于事件影响力评级,非收益预测
|
||||||
和
|
</Text>
|
||||||
<Text as="span" color="#00da3c" fontWeight="bold"> 利空</Text>
|
</HStack>
|
||||||
,请在「历史相关事件」中查看历史表现
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 延迟提醒 */}
|
{/* 涨跌幅说明 */}
|
||||||
<HStack
|
<HStack spacing={2} align="start">
|
||||||
spacing={2}
|
<Text color="cyan.400" fontWeight="bold" fontSize="xs" minW="50px">涨跌幅</Text>
|
||||||
p={2}
|
<Text fontSize="xs" color="whiteAlpha.800">
|
||||||
bg="rgba(255,100,0,0.1)"
|
新闻发布时股价 → 当前价格的变化
|
||||||
borderRadius="lg"
|
</Text>
|
||||||
border="1px solid rgba(255,100,0,0.2)"
|
</HStack>
|
||||||
w="100%"
|
|
||||||
>
|
{/* 延迟提醒 */}
|
||||||
<Icon as={Clock} color="orange.400" boxSize={4} />
|
<HStack spacing={2} align="start">
|
||||||
<Text fontSize="xs" color="whiteAlpha.800">
|
<HStack spacing={1} minW="50px">
|
||||||
数据延迟
|
<Icon as={Clock} color="orange.400" boxSize={3} />
|
||||||
<Text as="span" color="orange.400" fontWeight="bold"> 2-3分钟</Text>
|
</HStack>
|
||||||
,请
|
<Text fontSize="xs" color="whiteAlpha.800">
|
||||||
<Text as="span" color="#ec0000" fontWeight="bold"> 不要追高</Text>
|
数据延迟<Text as="span" color="orange.400" fontWeight="bold"> 2-3分钟</Text>
|
||||||
</Text>
|
,<Text as="span" color="#ec0000" fontWeight="bold">切勿追高</Text>
|
||||||
</HStack>
|
</Text>
|
||||||
</VStack>
|
</HStack>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<Box w="100%" h="1px" bg="rgba(255,255,255,0.1)" />
|
||||||
|
|
||||||
|
{/* 盘前新闻 */}
|
||||||
|
<HStack spacing={2} align="start">
|
||||||
|
<Icon as={AlertCircle} color="blue.300" boxSize={3} mt={0.5} />
|
||||||
|
<Text fontSize="xs" color="whiteAlpha.800">
|
||||||
|
关注<Text as="span" color="blue.300" fontWeight="bold">盘前新闻</Text>
|
||||||
|
(收盘后至次日开盘前的消息)
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 利好利空 */}
|
||||||
|
<HStack spacing={2} align="start">
|
||||||
|
<Icon as={TrendingUp} color="whiteAlpha.500" boxSize={3} mt={0.5} />
|
||||||
|
<Text fontSize="xs" color="whiteAlpha.800">
|
||||||
|
事件含
|
||||||
|
<Text as="span" color="#ec0000" fontWeight="bold"> 利好</Text>/
|
||||||
|
<Text as="span" color="#00da3c" fontWeight="bold">利空</Text>
|
||||||
|
,需自行判断
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -524,14 +430,14 @@ const InfoCard = () => {
|
|||||||
*/
|
*/
|
||||||
const HeroPanel = () => {
|
const HeroPanel = () => {
|
||||||
const gradientBg = 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 25%, #16213e 50%, #1a1a2e 75%, #0a0a0a 100%)';
|
const gradientBg = 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 25%, #16213e 50%, #1a1a2e 75%, #0a0a0a 100%)';
|
||||||
const borderColor = useColorModeValue('rgba(255, 215, 0, 0.4)', 'rgba(255, 215, 0, 0.3)');
|
const borderColor = useColorModeValue('rgba(255, 215, 0, 0.3)', 'rgba(255, 215, 0, 0.25)');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
bg={gradientBg}
|
bg={gradientBg}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
boxShadow="0 20px 60px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,215,0,0.15) inset, 0 0 80px rgba(255,215,0,0.05)"
|
boxShadow="0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,215,0,0.1) inset"
|
||||||
mb={6}
|
mb={6}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
position="relative"
|
position="relative"
|
||||||
@@ -539,31 +445,31 @@ const HeroPanel = () => {
|
|||||||
{/* 装饰性光晕 */}
|
{/* 装饰性光晕 */}
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top="-40%"
|
top="-50%"
|
||||||
right="-20%"
|
right="-30%"
|
||||||
width="700px"
|
width="600px"
|
||||||
height="700px"
|
height="600px"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
bg="radial-gradient(circle, rgba(255,215,0,0.15) 0%, transparent 70%)"
|
bg="radial-gradient(circle, rgba(255,215,0,0.08) 0%, transparent 70%)"
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
filter="blur(50px)"
|
filter="blur(40px)"
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
bottom="-30%"
|
bottom="-40%"
|
||||||
left="-15%"
|
left="-20%"
|
||||||
width="500px"
|
width="400px"
|
||||||
height="500px"
|
height="400px"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
bg="radial-gradient(circle, rgba(236,0,0,0.1) 0%, transparent 70%)"
|
bg="radial-gradient(circle, rgba(100,150,255,0.05) 0%, transparent 70%)"
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
filter="blur(60px)"
|
filter="blur(50px)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardBody p={6}>
|
<CardBody p={{ base: 4, md: 5 }}>
|
||||||
{/* 顶部标题行 */}
|
{/* 标题行:标题 + 使用说明 + 交易状态 */}
|
||||||
<Flex justify="space-between" align="center" mb={6}>
|
<Flex align="center" justify="space-between" mb={4} wrap="wrap" gap={2}>
|
||||||
<HStack spacing={4}>
|
<HStack spacing={3}>
|
||||||
<Heading size="lg">
|
<Heading size="lg">
|
||||||
<Text
|
<Text
|
||||||
bgGradient="linear(to-r, #FFD700, #FFA500, #FFD700)"
|
bgGradient="linear(to-r, #FFD700, #FFA500, #FFD700)"
|
||||||
@@ -575,111 +481,113 @@ const HeroPanel = () => {
|
|||||||
事件中心
|
事件中心
|
||||||
</Text>
|
</Text>
|
||||||
</Heading>
|
</Heading>
|
||||||
{isInTradingTime() && (
|
{/* 使用说明 - 悬浮提示 */}
|
||||||
<HStack
|
<InfoTooltip />
|
||||||
spacing={2}
|
|
||||||
px={3}
|
|
||||||
py={1}
|
|
||||||
borderRadius="full"
|
|
||||||
bg="rgba(0,218,60,0.1)"
|
|
||||||
border="1px solid rgba(0,218,60,0.3)"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
w="8px"
|
|
||||||
h="8px"
|
|
||||||
borderRadius="full"
|
|
||||||
bg="#00da3c"
|
|
||||||
animation="pulse 1.5s infinite"
|
|
||||||
boxShadow="0 0 10px #00da3c"
|
|
||||||
/>
|
|
||||||
<Text fontSize="xs" color="#00da3c" fontWeight="bold">
|
|
||||||
交易时段
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
|
{/* 右侧:交易状态 */}
|
||||||
|
{isInTradingTime() && (
|
||||||
|
<HStack
|
||||||
|
spacing={1.5}
|
||||||
|
px={2.5}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
bg="rgba(0,218,60,0.1)"
|
||||||
|
border="1px solid rgba(0,218,60,0.3)"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
w="6px"
|
||||||
|
h="6px"
|
||||||
|
borderRadius="full"
|
||||||
|
bg="#00da3c"
|
||||||
|
animation="pulse 1.5s infinite"
|
||||||
|
boxShadow="0 0 8px #00da3c"
|
||||||
|
/>
|
||||||
|
<Text fontSize="xs" color="#00da3c" fontWeight="bold">
|
||||||
|
交易中
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing={6}>
|
{/* 内容区:指数 + 概念 */}
|
||||||
{/* 左侧:使用说明 */}
|
<Flex
|
||||||
<Box>
|
direction={{ base: 'column', lg: 'row' }}
|
||||||
<InfoCard />
|
gap={4}
|
||||||
</Box>
|
>
|
||||||
|
{/* 左侧:双指数横向排列 */}
|
||||||
|
<Flex
|
||||||
|
direction={{ base: 'column', md: 'row' }}
|
||||||
|
gap={4}
|
||||||
|
flex={{ lg: '0 0 58%' }}
|
||||||
|
>
|
||||||
|
{/* 上证指数 */}
|
||||||
|
<Box
|
||||||
|
flex="1"
|
||||||
|
p={3}
|
||||||
|
bg="rgba(255,255,255,0.03)"
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid rgba(255,255,255,0.08)"
|
||||||
|
transition="all 0.3s"
|
||||||
|
_hover={{
|
||||||
|
bg: 'rgba(255,255,255,0.05)',
|
||||||
|
borderColor: 'rgba(236,0,0,0.3)',
|
||||||
|
boxShadow: '0 4px 20px rgba(236,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CompactIndexCard indexCode="000001" indexName="上证指数" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* 中间:沪深指数K线图 */}
|
{/* 深证成指 */}
|
||||||
<Box>
|
<Box
|
||||||
<VStack spacing={4} h="100%">
|
flex="1"
|
||||||
<Box
|
p={3}
|
||||||
w="100%"
|
bg="rgba(255,255,255,0.03)"
|
||||||
p={4}
|
borderRadius="xl"
|
||||||
bg="whiteAlpha.100"
|
border="1px solid rgba(255,255,255,0.08)"
|
||||||
borderRadius="xl"
|
transition="all 0.3s"
|
||||||
borderWidth="1px"
|
_hover={{
|
||||||
borderColor="whiteAlpha.200"
|
bg: 'rgba(255,255,255,0.05)',
|
||||||
backdropFilter="blur(10px)"
|
borderColor: 'rgba(0,218,60,0.3)',
|
||||||
boxShadow="0 4px 16px rgba(0,0,0,0.2)"
|
boxShadow: '0 4px 20px rgba(0,218,60,0.1)',
|
||||||
transition="all 0.3s"
|
}}
|
||||||
_hover={{
|
>
|
||||||
borderColor: 'rgba(236,0,0,0.4)',
|
<CompactIndexCard indexCode="399001" indexName="深证成指" />
|
||||||
boxShadow: '0 8px 32px rgba(236,0,0,0.2)',
|
</Box>
|
||||||
transform: 'translateY(-2px)'
|
</Flex>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MiniIndexChart indexCode="000001" indexName="上证指数" />
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
w="100%"
|
|
||||||
p={4}
|
|
||||||
bg="whiteAlpha.100"
|
|
||||||
borderRadius="xl"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor="whiteAlpha.200"
|
|
||||||
backdropFilter="blur(10px)"
|
|
||||||
boxShadow="0 4px 16px rgba(0,0,0,0.2)"
|
|
||||||
transition="all 0.3s"
|
|
||||||
_hover={{
|
|
||||||
borderColor: 'rgba(0,218,60,0.4)',
|
|
||||||
boxShadow: '0 8px 32px rgba(0,218,60,0.2)',
|
|
||||||
transform: 'translateY(-2px)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MiniIndexChart indexCode="399001" indexName="深证成指" />
|
|
||||||
</Box>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 右侧:3D概念球 */}
|
{/* 右侧:热门概念 */}
|
||||||
<Box>
|
<Box
|
||||||
<VStack spacing={2} h="100%">
|
flex={{ lg: '1' }}
|
||||||
<HStack w="100%" justify="space-between" px={2}>
|
minH={{ base: '150px', lg: '100px' }}
|
||||||
<HStack spacing={2}>
|
bg="rgba(0,0,0,0.2)"
|
||||||
<Icon as={TrendingUp} color="gold" boxSize={4} />
|
borderRadius="xl"
|
||||||
<Text
|
border="1px solid rgba(255,215,0,0.1)"
|
||||||
fontSize="sm"
|
overflow="hidden"
|
||||||
fontWeight="bold"
|
position="relative"
|
||||||
bgGradient="linear(to-r, #FFD700, #FFA500)"
|
>
|
||||||
bgClip="text"
|
{/* 标题 */}
|
||||||
>
|
<HStack
|
||||||
热门概念
|
position="absolute"
|
||||||
</Text>
|
top={2}
|
||||||
</HStack>
|
left={3}
|
||||||
<Text fontSize="xs" color="whiteAlpha.500">
|
zIndex={10}
|
||||||
点击查看详情
|
spacing={2}
|
||||||
</Text>
|
>
|
||||||
</HStack>
|
<Icon as={TrendingUp} color="gold" boxSize={3.5} />
|
||||||
<Box
|
<Text fontSize="xs" fontWeight="bold" color="gold">
|
||||||
w="100%"
|
热门概念
|
||||||
flex="1"
|
</Text>
|
||||||
bg="rgba(0,0,0,0.2)"
|
<Text fontSize="xs" color="whiteAlpha.400">
|
||||||
borderRadius="xl"
|
点击查看
|
||||||
border="1px solid rgba(255,215,0,0.1)"
|
</Text>
|
||||||
overflow="hidden"
|
</HStack>
|
||||||
>
|
|
||||||
<Concept3DSphere />
|
<Box pt={7} h="100%">
|
||||||
</Box>
|
<ConceptFloat />
|
||||||
</VStack>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</SimpleGrid>
|
</Flex>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user