This commit is contained in:
2026-01-13 18:25:35 +08:00
parent 257f1cae69
commit b13567237f
8 changed files with 570 additions and 621 deletions

View File

@@ -33,8 +33,7 @@
"adaptiveIcon": {
"foregroundImage": "./assets/logo.jpg",
"backgroundColor": "#000000"
},
"googleServicesFile": "./google-services.json"
}
},
"plugins": [
[

View File

@@ -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);
// 格式化日期时间

View File

@@ -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} />

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',
},
});

View File

@@ -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