ios app
This commit is contained in:
@@ -33,8 +33,7 @@
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/logo.jpg",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"googleServicesFile": "./google-services.json"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
|
||||
@@ -80,7 +80,8 @@ const getSurpriseColor = (score) => {
|
||||
|
||||
const EventCard = memo(({ event, onPress }) => {
|
||||
const importance = IMPORTANCE_CONFIG[event.importance] || IMPORTANCE_CONFIG.C;
|
||||
const surpriseScore = event.hot_score || event.expectation_surprise_score || 0;
|
||||
// 超预期分数使用 expectation_surprise_score 字段
|
||||
const surpriseScore = event.expectation_surprise_score || 0;
|
||||
const surpriseColors = getSurpriseColor(surpriseScore);
|
||||
|
||||
// 格式化日期时间
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useCallback, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, StatusBar } from 'react-native';
|
||||
import { ScrollView, StyleSheet, StatusBar, Share } from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
@@ -33,7 +33,6 @@ import RelatedConcepts from './RelatedConcepts';
|
||||
import EventComments from './EventComments';
|
||||
import HistoricalEvents from './HistoricalEvents';
|
||||
import TransmissionChain from './TransmissionChain';
|
||||
import SankeyFlow from './SankeyFlow';
|
||||
import StockDetailModal from './StockDetailModal';
|
||||
|
||||
// 重要性等级配置
|
||||
@@ -181,6 +180,36 @@ const EventDetail = ({ route, navigation }) => {
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// 分享事件
|
||||
const handleShare = useCallback(async () => {
|
||||
try {
|
||||
const shareUrl = `https://valuefrontier.cn/events/${eventId}`;
|
||||
const shareTitle = currentEvent?.title || '事件详情';
|
||||
const shareMessage = `【价值前沿】${shareTitle}\n\n查看详情: ${shareUrl}`;
|
||||
|
||||
const result = await Share.share({
|
||||
message: shareMessage,
|
||||
title: shareTitle,
|
||||
url: shareUrl, // iOS only
|
||||
});
|
||||
|
||||
if (result.action === Share.sharedAction) {
|
||||
toast.show({
|
||||
description: '分享成功',
|
||||
placement: 'top',
|
||||
bg: 'success.500',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('分享失败:', error);
|
||||
toast.show({
|
||||
description: '分享失败,请重试',
|
||||
placement: 'top',
|
||||
bg: 'danger.500',
|
||||
});
|
||||
}
|
||||
}, [eventId, currentEvent?.title, toast]);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeString) => {
|
||||
if (!timeString) return '';
|
||||
@@ -237,7 +266,7 @@ const EventDetail = ({ route, navigation }) => {
|
||||
rounded="3xl"
|
||||
p={5}
|
||||
>
|
||||
{/* 重要性 + 时间 */}
|
||||
{/* 重要性 + 时间 + 分享 */}
|
||||
<HStack justifyContent="space-between" alignItems="center" mb={4}>
|
||||
<LinearGradient
|
||||
colors={importance.gradient}
|
||||
@@ -249,11 +278,29 @@ const EventDetail = ({ route, navigation }) => {
|
||||
{importance.label}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
<HStack alignItems="center" space={1}>
|
||||
<Icon as={Ionicons} name="time-outline" size="xs" color="gray.400" />
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{formatTime(currentEvent.created_at)}
|
||||
</Text>
|
||||
<HStack alignItems="center" space={3}>
|
||||
<HStack alignItems="center" space={1}>
|
||||
<Icon as={Ionicons} name="time-outline" size="xs" color="gray.400" />
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{formatTime(currentEvent.created_at)}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 分享按钮 */}
|
||||
<Pressable
|
||||
onPress={handleShare}
|
||||
_pressed={{ opacity: 0.6 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Box
|
||||
bg="rgba(34, 197, 94, 0.15)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(34, 197, 94, 0.3)"
|
||||
rounded="full"
|
||||
p={2}
|
||||
>
|
||||
<Icon as={Ionicons} name="share-social-outline" size="sm" color="#22C55E" />
|
||||
</Box>
|
||||
</Pressable>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
@@ -293,6 +340,17 @@ const EventDetail = ({ route, navigation }) => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 相关股票 - 最重要,放在第一位 */}
|
||||
<RelatedStocks
|
||||
stocks={relatedStocks}
|
||||
quotes={stockQuotes}
|
||||
loading={loadingRelated}
|
||||
loadingQuotes={loadingQuotes}
|
||||
onStockPress={handleStockPress}
|
||||
showAll={showAllStocks}
|
||||
onShowAll={() => setShowAllStocks(true)}
|
||||
/>
|
||||
|
||||
{/* 市场影响卡片 */}
|
||||
<Box mx={4} rounded="3xl" overflow="hidden">
|
||||
<LinearGradient
|
||||
@@ -495,17 +553,6 @@ const EventDetail = ({ route, navigation }) => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 相关股票 - 放在第一位,最重要 */}
|
||||
<RelatedStocks
|
||||
stocks={relatedStocks}
|
||||
quotes={stockQuotes}
|
||||
loading={loadingRelated}
|
||||
loadingQuotes={loadingQuotes}
|
||||
onStockPress={handleStockPress}
|
||||
showAll={showAllStocks}
|
||||
onShowAll={() => setShowAllStocks(true)}
|
||||
/>
|
||||
|
||||
{/* 相关概念 */}
|
||||
<RelatedConcepts
|
||||
concepts={relatedConcepts}
|
||||
@@ -518,16 +565,8 @@ const EventDetail = ({ route, navigation }) => {
|
||||
{/* 传导链分析 */}
|
||||
<TransmissionChain eventId={eventId} />
|
||||
|
||||
{/* 影响流向 */}
|
||||
<SankeyFlow eventId={eventId} />
|
||||
|
||||
{/* 历史事件 */}
|
||||
<HistoricalEvents
|
||||
eventId={eventId}
|
||||
onEventPress={(event) => {
|
||||
navigation.push('EventDetail', { eventId: event.id, title: event.title });
|
||||
}}
|
||||
/>
|
||||
<HistoricalEvents eventId={eventId} />
|
||||
|
||||
{/* 社区讨论 */}
|
||||
<EventComments eventId={eventId} />
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import EventCard from './EventCard';
|
||||
import MainlineView from './MainlineView';
|
||||
import { gradients, importanceGradients } from '../../theme';
|
||||
import { gradients } from '../../theme';
|
||||
import {
|
||||
fetchEvents,
|
||||
setFilters,
|
||||
@@ -38,14 +38,6 @@ const VIEW_MODES = {
|
||||
MAINLINE: 'mainline',
|
||||
};
|
||||
|
||||
// 筛选选项
|
||||
const FILTER_OPTIONS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'S', label: '重大' },
|
||||
{ key: 'A', label: '重要' },
|
||||
{ key: 'hot', label: '最热' },
|
||||
];
|
||||
|
||||
// 时间范围选项
|
||||
const TIME_OPTIONS = [
|
||||
{ key: 'current-trading-day', label: '当前交易日' },
|
||||
@@ -59,7 +51,6 @@ const EventList = ({ navigation }) => {
|
||||
const dispatch = useDispatch();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [viewMode, setViewMode] = useState(VIEW_MODES.LIST);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [activeTime, setActiveTime] = useState('current-trading-day'); // 默认当前交易日
|
||||
|
||||
const {
|
||||
@@ -103,20 +94,6 @@ const EventList = ({ navigation }) => {
|
||||
dispatch(fetchEvents({ page: pagination.page + 1, refresh: false }));
|
||||
}, [dispatch, loading.events, pagination]);
|
||||
|
||||
// 切换筛选
|
||||
const handleFilterChange = useCallback((filterKey) => {
|
||||
setActiveFilter(filterKey);
|
||||
|
||||
if (filterKey === 'all') {
|
||||
dispatch(setFilters({ importance: '', sort: 'new' }));
|
||||
} else if (filterKey === 'hot') {
|
||||
dispatch(setFilters({ importance: '', sort: 'hot' }));
|
||||
} else {
|
||||
dispatch(setFilters({ importance: filterKey, sort: 'new' }));
|
||||
}
|
||||
dispatch(fetchEvents({ page: 1, refresh: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
// 切换时间范围
|
||||
const handleTimeChange = useCallback((timeKey) => {
|
||||
setActiveTime(timeKey);
|
||||
@@ -309,56 +286,6 @@ const EventList = ({ navigation }) => {
|
||||
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 12 }}
|
||||
>
|
||||
<HStack space={2} alignItems="center">
|
||||
{/* 重要性筛选 */}
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const isActive = activeFilter === option.key;
|
||||
// 为不同筛选项使用不同的渐变色
|
||||
const getGradientColors = () => {
|
||||
if (option.key === 'S') return importanceGradients.S;
|
||||
if (option.key === 'A') return importanceGradients.A;
|
||||
if (option.key === 'hot') return ['#D4AF37', '#F59E0B'];
|
||||
return gradients.primary;
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
key={option.key}
|
||||
onPress={() => handleFilterChange(option.key)}
|
||||
>
|
||||
{({ isPressed }) => (
|
||||
isActive ? (
|
||||
<LinearGradient
|
||||
colors={getGradientColors()}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.filterBadgeActive}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="bold" color="white">
|
||||
{option.label}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<Box
|
||||
bg="rgba(255, 255, 255, 0.06)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
rounded="xl"
|
||||
px={4}
|
||||
py={2}
|
||||
opacity={isPressed ? 0.7 : 1}
|
||||
>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box w="1px" h={5} bg="rgba(255,255,255,0.15)" mx={2} alignSelf="center" rounded="full" />
|
||||
|
||||
{/* 时间范围筛选 */}
|
||||
{TIME_OPTIONS.map((option) => {
|
||||
const isActive = activeTime === option.key;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 历史事件时间线组件 - HeroUI 风格
|
||||
* 展示与当前事件相关的历史事件
|
||||
* 展示与当前事件相关的历史事件(仅展示,不可点击)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, memo } from 'react';
|
||||
import React, { useState, useEffect, memo, useCallback } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
@@ -31,6 +31,19 @@ const formatDate = (dateString) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 格式化涨跌幅
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined) return '--';
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌颜色(涨红跌绿)
|
||||
const getChangeColor = (value) => {
|
||||
if (value === null || value === undefined) return '#64748B';
|
||||
return value >= 0 ? '#EF4444' : '#22C55E';
|
||||
};
|
||||
|
||||
// 获取相关性颜色
|
||||
const getRelevanceColor = (relevance) => {
|
||||
if (relevance >= 0.8) return '#10B981';
|
||||
@@ -45,10 +58,68 @@ const getRelevanceLabel = (relevance) => {
|
||||
return '低度相关';
|
||||
};
|
||||
|
||||
// 单个历史事件项
|
||||
const HistoricalEventItem = memo(({ event, index, isLast, onPress }) => {
|
||||
// 单个股票标签
|
||||
const StockTag = memo(({ stock }) => {
|
||||
const change = stock.event_day_change_pct;
|
||||
const changeColor = getChangeColor(change);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
bg="rgba(124, 58, 237, 0.12)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(124, 58, 237, 0.2)"
|
||||
rounded="lg"
|
||||
px={2}
|
||||
py={1}
|
||||
alignItems="center"
|
||||
space={1.5}
|
||||
mb={1}
|
||||
mr={1}
|
||||
>
|
||||
<Text fontSize="2xs" color="primary.300" fontWeight="medium">
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
{change !== null && change !== undefined && (
|
||||
<Text fontSize="2xs" fontWeight="bold" color={changeColor}>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
StockTag.displayName = 'StockTag';
|
||||
|
||||
// 单个历史事件项(不可点击)
|
||||
const HistoricalEventItem = memo(({ event, index, isLast }) => {
|
||||
const [stocks, setStocks] = useState([]);
|
||||
const [loadingStocks, setLoadingStocks] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const relevanceColor = getRelevanceColor(event.relevance);
|
||||
|
||||
// 加载该历史事件的相关股票
|
||||
useEffect(() => {
|
||||
const loadStocks = async () => {
|
||||
if (!event.id) return;
|
||||
setLoadingStocks(true);
|
||||
try {
|
||||
const response = await eventService.getHistoricalEventStocks(event.id);
|
||||
if (response.success && response.data) {
|
||||
setStocks(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史事件股票失败:', error);
|
||||
} finally {
|
||||
setLoadingStocks(false);
|
||||
}
|
||||
};
|
||||
loadStocks();
|
||||
}, [event.id]);
|
||||
|
||||
// 显示的股票数量
|
||||
const displayStocks = expanded ? stocks : stocks.slice(0, 4);
|
||||
const hasMoreStocks = stocks.length > 4 && !expanded;
|
||||
|
||||
return (
|
||||
<HStack>
|
||||
{/* 时间线 */}
|
||||
@@ -74,15 +145,14 @@ const HistoricalEventItem = memo(({ event, index, isLast, onPress }) => {
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 内容 */}
|
||||
<Pressable flex={1} onPress={() => onPress?.(event)} _pressed={{ opacity: 0.7 }}>
|
||||
{/* 内容(不可点击) */}
|
||||
<Box flex={1} mb={isLast ? 0 : 3}>
|
||||
<Box
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.05)"
|
||||
rounded="2xl"
|
||||
p={4}
|
||||
mb={isLast ? 0 : 3}
|
||||
>
|
||||
{/* 日期和相关性 */}
|
||||
<HStack justifyContent="space-between" alignItems="center" mb={2}>
|
||||
@@ -107,44 +177,44 @@ const HistoricalEventItem = memo(({ event, index, isLast, onPress }) => {
|
||||
</HStack>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text fontSize="sm" fontWeight="bold" color="white" mb={2} numberOfLines={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="white" mb={2}>
|
||||
{event.title}
|
||||
</Text>
|
||||
|
||||
{/* 内容摘要 */}
|
||||
{event.content && (
|
||||
<Text fontSize="xs" color="gray.400" numberOfLines={3} lineHeight="sm" mb={2}>
|
||||
<Text fontSize="xs" color="gray.400" numberOfLines={expanded ? undefined : 3} lineHeight="sm" mb={2}>
|
||||
{event.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 关联股票 */}
|
||||
{event.related_stock && event.related_stock.length > 0 && (
|
||||
<HStack flexWrap="wrap" space={1} mt={1}>
|
||||
{event.related_stock.slice(0, 3).map((stock, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
bg="rgba(124, 58, 237, 0.15)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(124, 58, 237, 0.25)"
|
||||
rounded="md"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
>
|
||||
<Text fontSize="2xs" color="primary.300">
|
||||
{typeof stock === 'string' ? stock : stock.name}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{event.related_stock.length > 3 && (
|
||||
<Text fontSize="2xs" color="gray.500">
|
||||
+{event.related_stock.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
{/* 相关股票 */}
|
||||
{loadingStocks ? (
|
||||
<HStack alignItems="center" space={2} mt={1}>
|
||||
<Spinner size="sm" color="primary.400" />
|
||||
<Text fontSize="2xs" color="gray.500">加载股票...</Text>
|
||||
</HStack>
|
||||
)}
|
||||
) : stocks.length > 0 ? (
|
||||
<VStack mt={2}>
|
||||
<Text fontSize="2xs" color="gray.500" mb={1}>
|
||||
当时相关股票 ({stocks.length}只)
|
||||
</Text>
|
||||
<HStack flexWrap="wrap">
|
||||
{displayStocks.map((stock, idx) => (
|
||||
<StockTag key={stock.id || idx} stock={stock} />
|
||||
))}
|
||||
</HStack>
|
||||
{hasMoreStocks && (
|
||||
<Pressable onPress={() => setExpanded(true)} mt={1}>
|
||||
<Text fontSize="2xs" color="primary.400">
|
||||
查看全部 {stocks.length} 只股票...
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</Box>
|
||||
</Pressable>
|
||||
</Box>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
@@ -152,26 +222,34 @@ const HistoricalEventItem = memo(({ event, index, isLast, onPress }) => {
|
||||
HistoricalEventItem.displayName = 'HistoricalEventItem';
|
||||
|
||||
// 历史事件组件
|
||||
const HistoricalEvents = ({ eventId, onEventPress, maxDisplay = 5 }) => {
|
||||
const HistoricalEvents = ({ eventId, maxDisplay = 5 }) => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// 加载历史事件
|
||||
useEffect(() => {
|
||||
const loadEvents = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('[HistoricalEvents] 开始加载, eventId:', eventId);
|
||||
const response = await eventService.getHistoricalEvents(eventId);
|
||||
console.log('[HistoricalEvents] API响应:', response);
|
||||
|
||||
if (response.success) {
|
||||
// 按日期排序
|
||||
const sorted = (response.data || []).sort(
|
||||
(a, b) => new Date(b.event_date) - new Date(a.event_date)
|
||||
);
|
||||
setEvents(sorted);
|
||||
} else {
|
||||
setError(response.error || response.message || '加载失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史事件失败:', error);
|
||||
} catch (err) {
|
||||
console.error('[HistoricalEvents] 加载失败:', err);
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -180,7 +258,7 @@ const HistoricalEvents = ({ eventId, onEventPress, maxDisplay = 5 }) => {
|
||||
}, [eventId]);
|
||||
|
||||
// 空状态不显示
|
||||
if (!loading && events.length === 0) {
|
||||
if (!loading && !error && events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -235,6 +313,11 @@ const HistoricalEvents = ({ eventId, onEventPress, maxDisplay = 5 }) => {
|
||||
加载历史事件...
|
||||
</Text>
|
||||
</Center>
|
||||
) : error ? (
|
||||
<Center py={6}>
|
||||
<Icon as={Ionicons} name="warning-outline" size="lg" color="gray.500" mb={2} />
|
||||
<Text fontSize="sm" color="gray.500">{error}</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack>
|
||||
{displayEvents.map((event, index) => (
|
||||
@@ -243,7 +326,6 @@ const HistoricalEvents = ({ eventId, onEventPress, maxDisplay = 5 }) => {
|
||||
event={event}
|
||||
index={index}
|
||||
isLast={index === displayEvents.length - 1}
|
||||
onPress={onEventPress}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 展示事件关联的股票列表
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { StyleSheet, Clipboard } from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Pressable,
|
||||
Spinner,
|
||||
Center,
|
||||
useToast,
|
||||
} from 'native-base';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -29,123 +30,114 @@ const formatChange = (value) => {
|
||||
// 获取涨跌幅颜色(中国标准:涨红跌绿)
|
||||
const getChangeColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.500';
|
||||
return value >= 0 ? '#EF4444' : '#22C55E'; // 涨红跌绿
|
||||
return value >= 0 ? '#EF4444' : '#22C55E';
|
||||
};
|
||||
|
||||
// 获取涨跌幅背景色(中国标准:涨红跌绿)
|
||||
const getChangeBgColor = (value) => {
|
||||
if (value === null || value === undefined) return 'rgba(100, 116, 139, 0.1)';
|
||||
return value >= 0 ? 'rgba(239, 68, 68, 0.15)' : 'rgba(34, 197, 94, 0.15)'; // 涨红跌绿
|
||||
return value >= 0 ? 'rgba(239, 68, 68, 0.15)' : 'rgba(34, 197, 94, 0.15)';
|
||||
};
|
||||
|
||||
// 获取关联描述文本
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
if (!relationDesc) return null;
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组(后端新格式)
|
||||
if (typeof relationDesc === 'string') return relationDesc;
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
return firstItem.query_part || firstItem.sentences || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 单个股票项
|
||||
const StockItem = memo(({ stock, quote, index, total, onPress }) => {
|
||||
const StockItem = memo(({ stock, quote, index, total, onPress, onCopyCode }) => {
|
||||
const isLast = index === total - 1;
|
||||
|
||||
// 使用报价数据或股票数据
|
||||
const stockName = quote?.name || stock.stock_name || stock.name || `股票${(stock.stock_code || '').split('.')[0]}`;
|
||||
const stockName = quote?.name || stock.stock_name || stock.name || `股票`;
|
||||
const stockCode = stock.stock_code || stock.code || '';
|
||||
const price = quote?.price ?? stock.price;
|
||||
const change = quote?.change ?? stock.change_percent;
|
||||
const relationDesc = getRelationDesc(stock.relation_desc);
|
||||
|
||||
return (
|
||||
<Pressable onPress={() => onPress?.(stock)} _pressed={{ opacity: 0.7 }}>
|
||||
<Box
|
||||
py={3.5}
|
||||
borderBottomWidth={isLast ? 0 : 1}
|
||||
borderBottomColor="rgba(255, 255, 255, 0.05)"
|
||||
>
|
||||
<HStack justifyContent="space-between" alignItems="flex-start">
|
||||
{/* 左侧:股票信息 */}
|
||||
<VStack flex={1} mr={3}>
|
||||
<Box
|
||||
py={3}
|
||||
borderBottomWidth={isLast ? 0 : 1}
|
||||
borderBottomColor="rgba(255, 255, 255, 0.05)"
|
||||
>
|
||||
<HStack justifyContent="space-between" alignItems="flex-start">
|
||||
{/* 左侧:股票信息 */}
|
||||
<Pressable
|
||||
flex={1}
|
||||
mr={3}
|
||||
onPress={() => onPress?.(stock)}
|
||||
_pressed={{ opacity: 0.7 }}
|
||||
>
|
||||
<VStack>
|
||||
{/* 股票名称和代码在一行 */}
|
||||
<HStack alignItems="center" space={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="white">
|
||||
{stockName}
|
||||
</Text>
|
||||
{stock.sector && (
|
||||
<Box
|
||||
bg="rgba(124, 58, 237, 0.15)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(124, 58, 237, 0.25)"
|
||||
rounded="md"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
>
|
||||
<Text fontSize="2xs" color="primary.300">
|
||||
{stock.sector}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{stockCode}
|
||||
</Text>
|
||||
{/* 复制按钮 */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onCopyCode?.(stockCode);
|
||||
}}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
_pressed={{ opacity: 0.5 }}
|
||||
>
|
||||
<Icon as={Ionicons} name="copy-outline" size="xs" color="gray.500" />
|
||||
</Pressable>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500" mt={0.5}>
|
||||
{stock.stock_code || stock.code}
|
||||
</Text>
|
||||
|
||||
{/* 关联原因 - 简洁显示 */}
|
||||
{relationDesc && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.400"
|
||||
mt={1.5}
|
||||
numberOfLines={2}
|
||||
mt={1}
|
||||
numberOfLines={1}
|
||||
lineHeight="sm"
|
||||
>
|
||||
{relationDesc}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Pressable>
|
||||
|
||||
{/* 右侧:涨跌幅和价格 */}
|
||||
<VStack alignItems="flex-end" minW={20}>
|
||||
<Box
|
||||
bg={getChangeBgColor(change)}
|
||||
rounded="lg"
|
||||
px={2.5}
|
||||
py={1}
|
||||
{/* 右侧:涨跌幅和价格 */}
|
||||
<VStack alignItems="flex-end" minW={20}>
|
||||
<Box
|
||||
bg={getChangeBgColor(change)}
|
||||
rounded="lg"
|
||||
px={2.5}
|
||||
py={1}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(change)}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(change)}
|
||||
>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
</Box>
|
||||
{price != null && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
¥{price.toFixed(2)}
|
||||
</Text>
|
||||
)}
|
||||
{stock.correlation !== undefined && stock.correlation !== null && (
|
||||
<HStack alignItems="center" mt={1} space={1}>
|
||||
<Icon as={Ionicons} name="git-network" size="2xs" color="gray.500" />
|
||||
<Text fontSize="2xs" color="gray.500">
|
||||
相关性 {(stock.correlation * 100).toFixed(0)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Pressable>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
</Box>
|
||||
{price != null && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
¥{price.toFixed(2)}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,6 +154,24 @@ const RelatedStocks = ({
|
||||
showAll = false,
|
||||
onShowAll,
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
|
||||
// 复制股票代码
|
||||
const handleCopyCode = useCallback((code) => {
|
||||
if (!code) return;
|
||||
try {
|
||||
Clipboard.setString(code);
|
||||
toast.show({
|
||||
description: `已复制 ${code}`,
|
||||
placement: 'top',
|
||||
bg: 'primary.500',
|
||||
duration: 1500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -230,23 +240,6 @@ const RelatedStocks = ({
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 表头 */}
|
||||
<HStack
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
pb={2}
|
||||
mb={1}
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="rgba(255, 255, 255, 0.08)"
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
股票名称 / 代码
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
涨跌幅 / 价格
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 股票列表 */}
|
||||
<VStack space={0}>
|
||||
{displayStocks.map((stock, index) => (
|
||||
@@ -257,6 +250,7 @@ const RelatedStocks = ({
|
||||
index={index}
|
||||
total={displayStocks.length}
|
||||
onPress={onStockPress}
|
||||
onCopyCode={handleCopyCode}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 传导链分析组件 - 桑基图风格
|
||||
* 传导链分析组件 - 简洁卡片列表风格
|
||||
* 展示事件的影响传导路径
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, memo, useMemo } from 'react';
|
||||
import { StyleSheet, Dimensions } from 'react-native';
|
||||
import React, { useState, useEffect, memo } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
@@ -14,266 +14,251 @@ import {
|
||||
Pressable,
|
||||
Spinner,
|
||||
Center,
|
||||
ScrollView,
|
||||
} from 'native-base';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Svg, { Path, Rect, G, Defs, LinearGradient as SvgGradient, Stop } from 'react-native-svg';
|
||||
import { gradients } from '../../theme';
|
||||
import eventService from '../../services/eventService';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 节点类型配置
|
||||
const NODE_TYPE_CONFIG = {
|
||||
event: { label: '事件', icon: 'flash', color: '#EF4444', bgColor: 'rgba(239, 68, 68, 0.15)' },
|
||||
policy: { label: '政策', icon: 'document-text', color: '#8B5CF6', bgColor: 'rgba(139, 92, 246, 0.15)' },
|
||||
technology: { label: '技术', icon: 'hardware-chip', color: '#06B6D4', bgColor: 'rgba(6, 182, 212, 0.15)' },
|
||||
industry: { label: '行业', icon: 'business', color: '#F59E0B', bgColor: 'rgba(245, 158, 11, 0.15)' },
|
||||
company: { label: '公司', icon: 'storefront', color: '#22C55E', bgColor: 'rgba(34, 197, 94, 0.15)' },
|
||||
market: { label: '市场', icon: 'trending-up', color: '#EC4899', bgColor: 'rgba(236, 72, 153, 0.15)' },
|
||||
other: { label: '其他', icon: 'ellipse', color: '#64748B', bgColor: 'rgba(100, 116, 139, 0.15)' },
|
||||
event: { label: '事件', icon: 'flash', gradient: ['#EF4444', '#DC2626'] },
|
||||
policy: { label: '政策', icon: 'document-text', gradient: ['#8B5CF6', '#7C3AED'] },
|
||||
technology: { label: '技术', icon: 'hardware-chip', gradient: ['#06B6D4', '#0891B2'] },
|
||||
industry: { label: '行业', icon: 'business', gradient: ['#F59E0B', '#D97706'] },
|
||||
company: { label: '公司', icon: 'storefront', gradient: ['#22C55E', '#16A34A'] },
|
||||
market: { label: '市场', icon: 'trending-up', gradient: ['#EC4899', '#DB2777'] },
|
||||
concept: { label: '概念', icon: 'bulb', gradient: ['#3B82F6', '#2563EB'] },
|
||||
other: { label: '其他', icon: 'ellipse', gradient: ['#64748B', '#475569'] },
|
||||
};
|
||||
|
||||
// 传导方向配置(涨红跌绿)
|
||||
// 影响方向配置
|
||||
const DIRECTION_CONFIG = {
|
||||
positive: { label: '利好', color: '#EF4444' },
|
||||
negative: { label: '利空', color: '#22C55E' },
|
||||
neutral: { label: '中性', color: '#64748B' },
|
||||
mixed: { label: '复杂', color: '#F59E0B' },
|
||||
positive: { label: '利好', color: '#EF4444', icon: 'arrow-up' },
|
||||
negative: { label: '利空', color: '#22C55E', icon: 'arrow-down' },
|
||||
neutral: { label: '中性', color: '#64748B', icon: 'remove' },
|
||||
};
|
||||
|
||||
// 计算桑基图布局
|
||||
const calculateSankeyLayout = (nodes, edges) => {
|
||||
if (nodes.length === 0) return { positions: {}, levels: [], width: 0, height: 0, nodeHeight: {} };
|
||||
|
||||
// 找到根节点
|
||||
const incomingEdges = new Set(edges.map(e => e.target));
|
||||
let root = nodes.find(n => n.extra?.is_main_event);
|
||||
if (!root) {
|
||||
root = nodes.find(n => !incomingEdges.has(n.id)) || nodes[0];
|
||||
}
|
||||
|
||||
// 构建邻接表
|
||||
const children = {};
|
||||
nodes.forEach(n => { children[n.id] = []; });
|
||||
edges.forEach(e => {
|
||||
if (children[e.source]) {
|
||||
const targetNode = nodes.find(n => n.id === e.target);
|
||||
if (targetNode) {
|
||||
children[e.source].push({ node: targetNode, edge: e });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// BFS 计算层级
|
||||
const levels = [];
|
||||
const visited = new Set();
|
||||
const nodeLevel = {};
|
||||
const queue = [{ node: root, level: 0 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { node, level } = queue.shift();
|
||||
if (visited.has(node.id)) continue;
|
||||
visited.add(node.id);
|
||||
|
||||
if (!levels[level]) levels[level] = [];
|
||||
levels[level].push(node);
|
||||
nodeLevel[node.id] = level;
|
||||
|
||||
const nodeChildren = children[node.id] || [];
|
||||
nodeChildren.forEach(({ node: child }) => {
|
||||
if (!visited.has(child.id)) {
|
||||
queue.push({ node: child, level: level + 1 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加未访问的节点
|
||||
nodes.forEach(n => {
|
||||
if (!visited.has(n.id)) {
|
||||
const lastLevel = levels.length > 0 ? levels.length - 1 : 0;
|
||||
if (!levels[lastLevel]) levels[lastLevel] = [];
|
||||
levels[lastLevel].push(n);
|
||||
nodeLevel[n.id] = lastLevel;
|
||||
}
|
||||
});
|
||||
|
||||
// 布局参数
|
||||
const NODE_WIDTH = 100;
|
||||
const NODE_MIN_HEIGHT = 40;
|
||||
const LEVEL_GAP = 80;
|
||||
const NODE_GAP = 15;
|
||||
const PADDING = 20;
|
||||
|
||||
// 计算每个节点的高度(基于重要性或连接数)
|
||||
const nodeHeight = {};
|
||||
nodes.forEach(n => {
|
||||
const importance = n.extra?.importance_score || 50;
|
||||
const connectionCount = edges.filter(e => e.source === n.id || e.target === n.id).length;
|
||||
nodeHeight[n.id] = Math.max(NODE_MIN_HEIGHT, 30 + Math.min(importance / 5, 20) + connectionCount * 5);
|
||||
});
|
||||
|
||||
// 计算位置
|
||||
const positions = {};
|
||||
let maxHeight = 0;
|
||||
|
||||
levels.forEach((levelNodes, levelIndex) => {
|
||||
// 计算本层总高度
|
||||
let totalHeight = 0;
|
||||
levelNodes.forEach(node => {
|
||||
totalHeight += nodeHeight[node.id];
|
||||
});
|
||||
totalHeight += (levelNodes.length - 1) * NODE_GAP;
|
||||
maxHeight = Math.max(maxHeight, totalHeight);
|
||||
});
|
||||
|
||||
levels.forEach((levelNodes, levelIndex) => {
|
||||
let currentY = PADDING;
|
||||
// 计算本层总高度
|
||||
let totalHeight = 0;
|
||||
levelNodes.forEach(node => {
|
||||
totalHeight += nodeHeight[node.id];
|
||||
});
|
||||
totalHeight += (levelNodes.length - 1) * NODE_GAP;
|
||||
|
||||
// 垂直居中
|
||||
currentY = (maxHeight - totalHeight) / 2 + PADDING;
|
||||
|
||||
levelNodes.forEach((node) => {
|
||||
const h = nodeHeight[node.id];
|
||||
positions[node.id] = {
|
||||
x: PADDING + levelIndex * (NODE_WIDTH + LEVEL_GAP),
|
||||
y: currentY,
|
||||
width: NODE_WIDTH,
|
||||
height: h,
|
||||
};
|
||||
currentY += h + NODE_GAP;
|
||||
});
|
||||
});
|
||||
|
||||
const totalWidth = PADDING * 2 + levels.length * NODE_WIDTH + (levels.length - 1) * LEVEL_GAP;
|
||||
const totalHeight = maxHeight + PADDING * 2;
|
||||
|
||||
return { positions, levels, width: totalWidth, height: totalHeight, nodeHeight, nodeLevel };
|
||||
};
|
||||
|
||||
// 绘制桑基图连接线
|
||||
const SankeyLinks = memo(({ edges, positions, nodes }) => {
|
||||
const paths = edges.map((edge, index) => {
|
||||
const sourcePos = positions[edge.source];
|
||||
const targetPos = positions[edge.target];
|
||||
|
||||
if (!sourcePos || !targetPos) return null;
|
||||
|
||||
const direction = DIRECTION_CONFIG[edge.extra?.direction] || DIRECTION_CONFIG.neutral;
|
||||
|
||||
// 桑基图风格的贝塞尔曲线
|
||||
const sourceX = sourcePos.x + sourcePos.width;
|
||||
const sourceY = sourcePos.y + sourcePos.height / 2;
|
||||
const targetX = targetPos.x;
|
||||
const targetY = targetPos.y + targetPos.height / 2;
|
||||
|
||||
const controlOffset = (targetX - sourceX) / 2;
|
||||
const d = `M ${sourceX} ${sourceY} C ${sourceX + controlOffset} ${sourceY}, ${targetX - controlOffset} ${targetY}, ${targetX} ${targetY}`;
|
||||
|
||||
// 根据节点高度计算线条宽度
|
||||
const strokeWidth = Math.max(4, Math.min(sourcePos.height, targetPos.height) * 0.4);
|
||||
|
||||
const gradientId = `gradient-${index}`;
|
||||
const sourceNode = nodes.find(n => n.id === edge.source);
|
||||
const targetNode = nodes.find(n => n.id === edge.target);
|
||||
const sourceColor = NODE_TYPE_CONFIG[sourceNode?.extra?.node_type]?.color || '#64748B';
|
||||
const targetColor = NODE_TYPE_CONFIG[targetNode?.extra?.node_type]?.color || '#64748B';
|
||||
|
||||
return (
|
||||
<G key={index}>
|
||||
<Defs>
|
||||
<SvgGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor={sourceColor} stopOpacity="0.6" />
|
||||
<Stop offset="100%" stopColor={targetColor} stopOpacity="0.6" />
|
||||
</SvgGradient>
|
||||
</Defs>
|
||||
<Path
|
||||
d={d}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
opacity={0.7}
|
||||
/>
|
||||
{/* 方向标记 */}
|
||||
<Rect
|
||||
x={(sourceX + targetX) / 2 - 8}
|
||||
y={(sourceY + targetY) / 2 - 8}
|
||||
width={16}
|
||||
height={16}
|
||||
rx={8}
|
||||
fill={direction.color}
|
||||
opacity={0.9}
|
||||
/>
|
||||
</G>
|
||||
);
|
||||
});
|
||||
|
||||
return <>{paths}</>;
|
||||
});
|
||||
|
||||
SankeyLinks.displayName = 'SankeyLinks';
|
||||
|
||||
// 单个桑基图节点
|
||||
const SankeyNode = memo(({ node, position, onPress }) => {
|
||||
// 单个传导节点卡片
|
||||
const ChainNode = memo(({ node, isFirst, isLast, direction }) => {
|
||||
const config = NODE_TYPE_CONFIG[node.extra?.node_type] || NODE_TYPE_CONFIG.other;
|
||||
const isMainEvent = node.extra?.is_main_event;
|
||||
const dirConfig = DIRECTION_CONFIG[direction] || DIRECTION_CONFIG.neutral;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onPress?.(node)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: position.width,
|
||||
height: position.height,
|
||||
}}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={isMainEvent ? ['rgba(124, 58, 237, 0.4)', 'rgba(124, 58, 237, 0.2)'] : [config.bgColor, config.bgColor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[
|
||||
styles.sankeyNode,
|
||||
{
|
||||
borderColor: isMainEvent ? '#7C3AED' : config.color,
|
||||
borderWidth: isMainEvent ? 2 : 1,
|
||||
height: '100%',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<VStack alignItems="center" justifyContent="center" flex={1} space={1}>
|
||||
<Icon as={Ionicons} name={config.icon} size="xs" color={config.color} />
|
||||
<Text
|
||||
fontSize="2xs"
|
||||
fontWeight="bold"
|
||||
color="white"
|
||||
textAlign="center"
|
||||
numberOfLines={2}
|
||||
<VStack alignItems="center">
|
||||
{/* 连接线 - 上方 */}
|
||||
{!isFirst && (
|
||||
<VStack alignItems="center" mb={2}>
|
||||
<Box w="2px" h={4} bg="rgba(255,255,255,0.15)" />
|
||||
<Box
|
||||
bg={`${dirConfig.color}20`}
|
||||
borderWidth={1}
|
||||
borderColor={`${dirConfig.color}40`}
|
||||
rounded="full"
|
||||
px={2}
|
||||
py={0.5}
|
||||
>
|
||||
{node.name?.length > 8 ? node.name.slice(0, 8) + '...' : node.name}
|
||||
</Text>
|
||||
<HStack alignItems="center" space={1}>
|
||||
<Icon as={Ionicons} name={dirConfig.icon} size="2xs" color={dirConfig.color} />
|
||||
<Text fontSize="2xs" color={dirConfig.color} fontWeight="medium">
|
||||
{dirConfig.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box w="2px" h={4} bg="rgba(255,255,255,0.15)" />
|
||||
</VStack>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* 节点卡片 */}
|
||||
<Box w="100%" maxW="280px">
|
||||
<LinearGradient
|
||||
colors={isMainEvent ? ['rgba(124, 58, 237, 0.25)', 'rgba(124, 58, 237, 0.1)'] : ['rgba(30, 41, 59, 0.9)', 'rgba(30, 41, 59, 0.7)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[
|
||||
styles.nodeCard,
|
||||
isMainEvent && styles.mainEventCard,
|
||||
]}
|
||||
>
|
||||
<HStack alignItems="center" space={3}>
|
||||
{/* 图标 */}
|
||||
<LinearGradient
|
||||
colors={config.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.nodeIcon}
|
||||
>
|
||||
<Icon as={Ionicons} name={config.icon} size="sm" color="white" />
|
||||
</LinearGradient>
|
||||
|
||||
{/* 内容 */}
|
||||
<VStack flex={1}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color="white"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
<HStack alignItems="center" mt={1} space={2}>
|
||||
<Box
|
||||
bg={`${config.gradient[0]}20`}
|
||||
rounded="md"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
>
|
||||
<Text fontSize="2xs" color={config.gradient[0]}>
|
||||
{config.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{node.extra?.importance_score !== undefined && (
|
||||
<Text fontSize="2xs" color="gray.500">
|
||||
重要性 {node.extra.importance_score}%
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 主事件标记 */}
|
||||
{isMainEvent && (
|
||||
<Box
|
||||
bg="rgba(124, 58, 237, 0.3)"
|
||||
rounded="full"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Text fontSize="2xs" color="#A78BFA" fontWeight="bold">
|
||||
源
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 描述 */}
|
||||
{node.extra?.description && (
|
||||
<Text fontSize="xs" color="gray.400" mt={2} numberOfLines={2}>
|
||||
{node.extra.description}
|
||||
</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
|
||||
SankeyNode.displayName = 'SankeyNode';
|
||||
ChainNode.displayName = 'ChainNode';
|
||||
|
||||
// 构建传导链路径 - 按层级排序所有节点
|
||||
const buildChainPath = (nodes, edges) => {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
// 构建双向邻接表和边映射
|
||||
const outgoing = {}; // source -> [targets]
|
||||
const incoming = {}; // target -> [sources]
|
||||
const edgeMap = {};
|
||||
|
||||
nodes.forEach(n => {
|
||||
outgoing[n.id] = [];
|
||||
incoming[n.id] = [];
|
||||
});
|
||||
|
||||
edges.forEach(e => {
|
||||
if (outgoing[e.source] && incoming[e.target]) {
|
||||
const targetNode = nodes.find(n => n.id === e.target);
|
||||
const sourceNode = nodes.find(n => n.id === e.source);
|
||||
if (targetNode) outgoing[e.source].push(targetNode);
|
||||
if (sourceNode) incoming[e.target].push(sourceNode);
|
||||
edgeMap[`${e.source}-${e.target}`] = e;
|
||||
}
|
||||
});
|
||||
|
||||
// 找到主事件节点(根节点)
|
||||
let mainNode = nodes.find(n => n.extra?.is_main_event);
|
||||
if (!mainNode) {
|
||||
// 找没有入边的节点作为根
|
||||
const noIncoming = nodes.filter(n => incoming[n.id].length === 0);
|
||||
mainNode = noIncoming[0] || nodes[0];
|
||||
}
|
||||
|
||||
// BFS 计算每个节点的层级(从主节点开始,双向遍历)
|
||||
const levels = {};
|
||||
const visited = new Set();
|
||||
const queue = [{ node: mainNode, level: 0, prevId: null }];
|
||||
const nodeEdgeInfo = {}; // 存储节点的入边信息
|
||||
|
||||
// 先用BFS从主节点向下遍历
|
||||
while (queue.length > 0) {
|
||||
const { node, level, prevId } = queue.shift();
|
||||
if (visited.has(node.id)) continue;
|
||||
visited.add(node.id);
|
||||
levels[node.id] = level;
|
||||
|
||||
// 记录入边方向
|
||||
if (prevId) {
|
||||
const edge = edgeMap[`${prevId}-${node.id}`];
|
||||
nodeEdgeInfo[node.id] = edge?.extra?.direction || 'neutral';
|
||||
}
|
||||
|
||||
// 向下遍历(outgoing edges)
|
||||
outgoing[node.id].forEach(child => {
|
||||
if (!visited.has(child.id)) {
|
||||
queue.push({ node: child, level: level + 1, prevId: node.id });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理未访问的节点(可能是反向连接或孤立的)
|
||||
nodes.forEach(n => {
|
||||
if (!visited.has(n.id)) {
|
||||
// 尝试找到连接到已访问节点的边
|
||||
let foundLevel = null;
|
||||
let foundDirection = 'neutral';
|
||||
|
||||
// 检查是否有边连接到已访问的节点
|
||||
incoming[n.id].forEach(sourceNode => {
|
||||
if (levels[sourceNode.id] !== undefined) {
|
||||
foundLevel = levels[sourceNode.id] + 1;
|
||||
const edge = edgeMap[`${sourceNode.id}-${n.id}`];
|
||||
foundDirection = edge?.extra?.direction || 'neutral';
|
||||
}
|
||||
});
|
||||
|
||||
outgoing[n.id].forEach(targetNode => {
|
||||
if (levels[targetNode.id] !== undefined && foundLevel === null) {
|
||||
foundLevel = levels[targetNode.id] - 1;
|
||||
}
|
||||
});
|
||||
|
||||
levels[n.id] = foundLevel !== null ? foundLevel : 999;
|
||||
nodeEdgeInfo[n.id] = foundDirection;
|
||||
}
|
||||
});
|
||||
|
||||
// 按层级排序节点
|
||||
const sortedNodes = [...nodes].sort((a, b) => {
|
||||
const levelA = levels[a.id] ?? 999;
|
||||
const levelB = levels[b.id] ?? 999;
|
||||
if (levelA !== levelB) return levelA - levelB;
|
||||
// 同层级按重要性排序
|
||||
const scoreA = a.extra?.importance_score ?? 0;
|
||||
const scoreB = b.extra?.importance_score ?? 0;
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
// 构建路径结果
|
||||
return sortedNodes.map(node => ({
|
||||
node,
|
||||
direction: nodeEdgeInfo[node.id] || 'neutral',
|
||||
}));
|
||||
};
|
||||
|
||||
// 传导链分析组件
|
||||
const TransmissionChain = ({ eventId }) => {
|
||||
const [data, setData] = useState({ nodes: [], edges: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
|
||||
// 加载传导链数据
|
||||
useEffect(() => {
|
||||
@@ -281,19 +266,26 @@ const TransmissionChain = ({ eventId }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('[TransmissionChain] 开始加载数据, eventId:', eventId);
|
||||
const response = await eventService.getTransmissionChain(eventId);
|
||||
console.log('[TransmissionChain] API响应:', JSON.stringify(response, null, 2));
|
||||
|
||||
if (response.success) {
|
||||
setData({
|
||||
nodes: response.data.nodes || [],
|
||||
edges: response.data.edges || [],
|
||||
});
|
||||
const nodes = response.data?.nodes || [];
|
||||
const edges = response.data?.edges || [];
|
||||
console.log('[TransmissionChain] 节点数量:', nodes.length, '边数量:', edges.length);
|
||||
console.log('[TransmissionChain] 节点列表:', nodes.map(n => ({ id: n.id, name: n.name })));
|
||||
setData({ nodes, edges });
|
||||
} else {
|
||||
if (response.message?.includes('订阅') || response.message?.includes('Max') || response.status === 403) {
|
||||
console.log('[TransmissionChain] API返回失败:', response);
|
||||
if (response.message?.includes('订阅') || response.message?.includes('Max') || response.status === 403 || response.error?.includes('Max')) {
|
||||
setError('需要 Max 订阅才能查看传导链分析');
|
||||
} else {
|
||||
setError(response.message || response.error || '暂无数据');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载传导链失败:', error);
|
||||
} catch (err) {
|
||||
console.error('[TransmissionChain] 加载传导链失败:', err);
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -302,15 +294,16 @@ const TransmissionChain = ({ eventId }) => {
|
||||
loadData();
|
||||
}, [eventId]);
|
||||
|
||||
// 计算布局
|
||||
const layout = useMemo(() => {
|
||||
return calculateSankeyLayout(data.nodes, data.edges);
|
||||
}, [data.nodes, data.edges]);
|
||||
// 构建链路径
|
||||
const chainPath = buildChainPath(data.nodes, data.edges);
|
||||
|
||||
// 点击节点
|
||||
const handleNodePress = (node) => {
|
||||
setSelectedNode(selectedNode?.id === node.id ? null : node);
|
||||
};
|
||||
// 调试日志
|
||||
useEffect(() => {
|
||||
if (data.nodes.length > 0) {
|
||||
console.log('[TransmissionChain] 构建路径结果:', chainPath.length, '个节点');
|
||||
console.log('[TransmissionChain] 路径详情:', chainPath.map(p => ({ name: p.node.name, direction: p.direction })));
|
||||
}
|
||||
}, [data.nodes, data.edges]);
|
||||
|
||||
// 空状态或错误
|
||||
if (!loading && (data.nodes.length === 0 || error)) {
|
||||
@@ -350,9 +343,6 @@ const TransmissionChain = ({ eventId }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerWidth = Math.max(layout.width + 40, SCREEN_WIDTH - 32);
|
||||
const containerHeight = layout.height + 20;
|
||||
|
||||
return (
|
||||
<Box mx={4} rounded="3xl" overflow="hidden">
|
||||
<Box
|
||||
@@ -363,7 +353,7 @@ const TransmissionChain = ({ eventId }) => {
|
||||
p={4}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<HStack alignItems="center" justifyContent="space-between" mb={3}>
|
||||
<HStack alignItems="center" justifyContent="space-between" mb={4}>
|
||||
<HStack alignItems="center">
|
||||
<LinearGradient
|
||||
colors={gradients.blue}
|
||||
@@ -377,46 +367,22 @@ const TransmissionChain = ({ eventId }) => {
|
||||
传导链分析
|
||||
</Text>
|
||||
</HStack>
|
||||
{data.nodes.length > 0 && (
|
||||
<HStack space={2}>
|
||||
<Box
|
||||
bg="rgba(59, 130, 246, 0.2)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(59, 130, 246, 0.3)"
|
||||
rounded="full"
|
||||
px={2}
|
||||
py={0.5}
|
||||
>
|
||||
<Text fontSize="2xs" color="blue.400">
|
||||
{data.nodes.length}节点
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
bg="rgba(6, 182, 212, 0.2)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(6, 182, 212, 0.3)"
|
||||
rounded="full"
|
||||
px={2}
|
||||
py={0.5}
|
||||
>
|
||||
<Text fontSize="2xs" color="secondary.400">
|
||||
{data.edges.length}连接
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
{chainPath.length > 0 && (
|
||||
<Box
|
||||
bg="rgba(59, 130, 246, 0.2)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(59, 130, 246, 0.3)"
|
||||
rounded="full"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
<Text fontSize="xs" color="blue.400">
|
||||
{chainPath.length} 个节点
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 图例 */}
|
||||
<HStack flexWrap="wrap" mb={3} space={2}>
|
||||
{Object.entries(DIRECTION_CONFIG).slice(0, 2).map(([key, config]) => (
|
||||
<HStack key={key} alignItems="center" space={1}>
|
||||
<Box w={2} h={2} bg={config.color} rounded="full" />
|
||||
<Text fontSize="2xs" color="gray.500">{config.label}</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
@@ -426,98 +392,17 @@ const TransmissionChain = ({ eventId }) => {
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={true}
|
||||
contentContainerStyle={{ minWidth: containerWidth }}
|
||||
>
|
||||
<Box style={{ width: containerWidth, height: containerHeight }}>
|
||||
{/* SVG 连接线 */}
|
||||
<Svg
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<SankeyLinks
|
||||
edges={data.edges}
|
||||
positions={layout.positions}
|
||||
nodes={data.nodes}
|
||||
/>
|
||||
</Svg>
|
||||
|
||||
{/* 节点 */}
|
||||
{data.nodes.map((node) => (
|
||||
<SankeyNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
position={layout.positions[node.id] || { x: 0, y: 0, width: 100, height: 40 }}
|
||||
onPress={handleNodePress}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* 选中节点详情 */}
|
||||
{selectedNode && (
|
||||
<Box
|
||||
mt={3}
|
||||
p={3}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
rounded="xl"
|
||||
>
|
||||
<HStack alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<HStack alignItems="center" space={2}>
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name={NODE_TYPE_CONFIG[selectedNode.extra?.node_type]?.icon || 'ellipse'}
|
||||
size="sm"
|
||||
color={NODE_TYPE_CONFIG[selectedNode.extra?.node_type]?.color || '#64748B'}
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="bold" color="white">
|
||||
{selectedNode.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Pressable onPress={() => setSelectedNode(null)}>
|
||||
<Icon as={Ionicons} name="close" size="sm" color="gray.500" />
|
||||
</Pressable>
|
||||
</HStack>
|
||||
<Box
|
||||
bg={NODE_TYPE_CONFIG[selectedNode.extra?.node_type]?.bgColor || 'rgba(100, 116, 139, 0.15)'}
|
||||
rounded="md"
|
||||
px={2}
|
||||
py={0.5}
|
||||
alignSelf="flex-start"
|
||||
mb={2}
|
||||
>
|
||||
<Text fontSize="2xs" color={NODE_TYPE_CONFIG[selectedNode.extra?.node_type]?.color || '#64748B'}>
|
||||
{NODE_TYPE_CONFIG[selectedNode.extra?.node_type]?.label || '其他'}
|
||||
</Text>
|
||||
</Box>
|
||||
{selectedNode.extra?.description && (
|
||||
<Text fontSize="xs" color="gray.400" mb={2}>
|
||||
{selectedNode.extra.description}
|
||||
</Text>
|
||||
)}
|
||||
{selectedNode.extra?.importance_score !== undefined && (
|
||||
<HStack alignItems="center">
|
||||
<Text fontSize="2xs" color="gray.500" mr={2}>重要性:</Text>
|
||||
<Box flex={1} h={1.5} bg="rgba(255,255,255,0.1)" rounded="full">
|
||||
<Box
|
||||
w={`${selectedNode.extra.importance_score}%`}
|
||||
h="100%"
|
||||
bg={NODE_TYPE_CONFIG[selectedNode.extra?.node_type]?.color || '#64748B'}
|
||||
rounded="full"
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="2xs" color="gray.500" ml={2}>
|
||||
{selectedNode.extra.importance_score}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
<VStack space={0} alignItems="center">
|
||||
{chainPath.map((item, index) => (
|
||||
<ChainNode
|
||||
key={item.node.id}
|
||||
node={item.node}
|
||||
isFirst={index === 0}
|
||||
isLast={index === chainPath.length - 1}
|
||||
direction={item.direction}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -532,9 +417,22 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sankeyNode: {
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
nodeCard: {
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
mainEventCard: {
|
||||
borderColor: 'rgba(124, 58, 237, 0.4)',
|
||||
borderWidth: 2,
|
||||
},
|
||||
nodeIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -147,6 +147,15 @@ export const eventService = {
|
||||
return await apiRequest(`/api/events/${eventId}/historical`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取历史事件相关股票
|
||||
* @param {number} historicalEventId - 历史事件ID(不是主事件ID)
|
||||
* @returns {Promise<object>} 相关股票列表
|
||||
*/
|
||||
getHistoricalEventStocks: async (historicalEventId) => {
|
||||
return await apiRequest(`/api/historical-events/${historicalEventId}/stocks`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取事件帖子
|
||||
* @param {number} eventId - 事件ID
|
||||
|
||||
Reference in New Issue
Block a user