This commit is contained in:
2026-01-13 15:10:13 +08:00
parent 38f0885a85
commit 45d5debead
134 changed files with 16980 additions and 1 deletions

View File

@@ -0,0 +1,15 @@
/**
* 组件导出
* 从原模板的 components 目录重新导出
*/
export { default as Button } from '../../components/Button';
export { default as Card } from '../../components/Card';
export { default as Header } from '../../components/Header';
export { default as Icon } from '../../components/Icon';
export { default as Input } from '../../components/Input';
export { default as Notification } from '../../components/Notification';
export { default as Select } from '../../components/Select';
export { default as Switch } from '../../components/Switch';
export { default as Tabs } from '../../components/Tabs';
export { default as DrawerItem } from '../../components/DrawerItem';

View File

@@ -0,0 +1,11 @@
/**
* 常量导出
* 从原模板的 constants 目录重新导出
*/
import argonTheme from '../../constants/Theme';
import Images from '../../constants/Images';
import articles from '../../constants/articles';
import tabs from '../../constants/tabs';
export { argonTheme, Images, articles, tabs };

View File

@@ -0,0 +1,156 @@
/**
* 认证上下文
* 管理用户登录状态和信息
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import authService from '../services/authService';
// 创建上下文
const AuthContext = createContext(null);
/**
* 认证 Provider 组件
*/
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [subscription, setSubscription] = useState(null);
// 初始化:检查本地登录状态并验证
useEffect(() => {
const initAuth = async () => {
try {
setIsLoading(true);
// 先检查本地是否有登录状态
const storedUser = await authService.getStoredUser();
if (storedUser) {
setUser(storedUser);
setIsLoggedIn(true);
// 然后尝试从服务器获取最新用户信息
const serverUser = await authService.getCurrentUser();
if (serverUser) {
setUser(serverUser);
// 获取订阅信息
const subInfo = await authService.getSubscription();
if (subInfo.success) {
setSubscription(subInfo.data || subInfo.subscription);
}
} else {
// 服务器验证失败,清除本地状态
setUser(null);
setIsLoggedIn(false);
}
}
} catch (error) {
console.error('[AuthContext] 初始化失败:', error);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
// 发送验证码
const sendCode = useCallback(async (phone) => {
return await authService.sendVerificationCode(phone);
}, []);
// 验证码登录
const loginWithCode = useCallback(async (phone, code) => {
const result = await authService.loginWithCode(phone, code);
if (result.success && result.user) {
setUser(result.user);
setIsLoggedIn(true);
// 获取订阅信息
try {
const subInfo = await authService.getSubscription();
if (subInfo.success) {
setSubscription(subInfo.data || subInfo.subscription);
}
} catch (e) {
console.warn('[AuthContext] 获取订阅信息失败:', e);
}
}
return result;
}, []);
// 退出登录
const logout = useCallback(async () => {
await authService.logout();
setUser(null);
setIsLoggedIn(false);
setSubscription(null);
}, []);
// 刷新用户信息
const refreshUser = useCallback(async () => {
try {
const serverUser = await authService.getCurrentUser();
if (serverUser) {
setUser(serverUser);
setIsLoggedIn(true);
// 同时刷新订阅信息
const subInfo = await authService.getSubscription();
if (subInfo.success) {
setSubscription(subInfo.data || subInfo.subscription);
}
}
return serverUser;
} catch (error) {
console.error('[AuthContext] 刷新用户信息失败:', error);
return null;
}
}, []);
// 检查是否有指定订阅级别
const hasSubscriptionLevel = useCallback(
(requiredLevel) => {
if (!subscription || !subscription.is_active) {
return false;
}
const levels = { free: 0, pro: 1, max: 2 };
const currentLevel = levels[subscription.type] || 0;
const required = levels[requiredLevel] || 0;
return currentLevel >= required;
},
[subscription]
);
const value = {
user,
isLoading,
isLoggedIn,
subscription,
sendCode,
loginWithCode,
logout,
refreshUser,
hasSubscriptionLevel,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
/**
* 使用认证上下文的 Hook
*/
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export default AuthContext;

View File

@@ -0,0 +1,398 @@
/**
* 登录页面 - 手机号验证码登录
* 深色主题 + 渐变设计
*/
import React, { useState, useEffect, useRef } from 'react';
import {
StyleSheet,
StatusBar,
KeyboardAvoidingView,
Platform,
Keyboard,
TouchableWithoutFeedback,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Input,
Button,
Icon,
Pressable,
useToast,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAuth } from '../../contexts/AuthContext';
import { gradients } from '../../theme';
const LoginScreen = ({ navigation }) => {
const insets = useSafeAreaInsets();
const toast = useToast();
const { sendCode, loginWithCode } = useAuth();
// 状态
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [step, setStep] = useState(1); // 1: 输入手机号, 2: 输入验证码
const [countdown, setCountdown] = useState(0);
const [loading, setLoading] = useState(false);
const [sendingCode, setSendingCode] = useState(false);
const codeInputRef = useRef(null);
const countdownRef = useRef(null);
// 倒计时逻辑
useEffect(() => {
if (countdown > 0) {
countdownRef.current = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
}
return () => {
if (countdownRef.current) {
clearTimeout(countdownRef.current);
}
};
}, [countdown]);
// 验证手机号格式
const isValidPhone = (phoneNum) => {
return /^1[3-9]\d{9}$/.test(phoneNum);
};
// 发送验证码
const handleSendCode = async () => {
if (!isValidPhone(phone)) {
toast.show({
description: '请输入正确的手机号',
placement: 'top',
bg: 'danger.500',
});
return;
}
setSendingCode(true);
try {
const result = await sendCode(phone);
if (result.success) {
setStep(2);
setCountdown(60);
toast.show({
description: '验证码已发送',
placement: 'top',
bg: 'success.500',
});
// 自动聚焦到验证码输入框
setTimeout(() => {
codeInputRef.current?.focus();
}, 100);
} else {
toast.show({
description: result.error || '发送失败,请稍后重试',
placement: 'top',
bg: 'danger.500',
});
}
} catch (error) {
toast.show({
description: '网络错误,请检查网络连接',
placement: 'top',
bg: 'danger.500',
});
} finally {
setSendingCode(false);
}
};
// 登录
const handleLogin = async () => {
if (code.length !== 6) {
toast.show({
description: '请输入6位验证码',
placement: 'top',
bg: 'danger.500',
});
return;
}
setLoading(true);
try {
const result = await loginWithCode(phone, code);
if (result.success) {
toast.show({
description: result.is_new_user ? '注册成功,欢迎使用!' : '登录成功',
placement: 'top',
bg: 'success.500',
});
// 登录成功后自动返回
setTimeout(() => {
navigation.goBack();
}, 500);
} else {
toast.show({
description: result.error || '登录失败,请重试',
placement: 'top',
bg: 'danger.500',
});
}
} catch (error) {
toast.show({
description: '网络错误,请检查网络连接',
placement: 'top',
bg: 'danger.500',
});
} finally {
setLoading(false);
}
};
// 返回上一步
const handleBack = () => {
if (step === 2) {
setStep(1);
setCode('');
} else {
navigation.goBack();
}
};
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
{/* 背景渐变 */}
<LinearGradient
colors={['#1E293B', '#0F172A']}
style={StyleSheet.absoluteFill}
/>
<VStack flex={1} px={6} pt={insets.top + 20}>
{/* 返回按钮 */}
<Pressable onPress={handleBack} mb={8}>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="chevron-back" size="md" color="gray.400" />
<Text color="gray.400" fontSize="sm">
{step === 2 ? '修改手机号' : '返回'}
</Text>
</HStack>
</Pressable>
{/* Logo 和标题 */}
<Center mb={12}>
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.logoContainer}
>
<Icon as={Ionicons} name="flash" size="2xl" color="white" />
</LinearGradient>
<Text fontSize="2xl" fontWeight="bold" color="white" mt={4}>
ValueFrontier
</Text>
<Text fontSize="sm" color="gray.500" mt={1}>
{step === 1 ? '输入手机号登录或注册' : '输入验证码完成登录'}
</Text>
</Center>
{/* 输入区域 */}
<VStack space={4}>
{/* 手机号输入 */}
<Box>
<Text fontSize="xs" color="gray.500" mb={2}>
手机号
</Text>
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor={step === 1 ? 'primary.500' : 'rgba(255, 255, 255, 0.1)'}
rounded="xl"
overflow="hidden"
>
<HStack alignItems="center" px={4}>
<Text color="gray.400" fontSize="md" mr={2}>
+86
</Text>
<Box w="1px" h={5} bg="rgba(255, 255, 255, 0.1)" mr={3} />
<Input
flex={1}
variant="unstyled"
placeholder="请输入手机号"
placeholderTextColor="gray.600"
color="white"
fontSize="md"
py={4}
keyboardType="phone-pad"
maxLength={11}
value={phone}
onChangeText={setPhone}
editable={step === 1}
/>
{phone.length > 0 && step === 1 && (
<Pressable onPress={() => setPhone('')}>
<Icon as={Ionicons} name="close-circle" size="sm" color="gray.500" />
</Pressable>
)}
</HStack>
</Box>
</Box>
{/* 验证码输入 - 仅在第二步显示 */}
{step === 2 && (
<Box>
<Text fontSize="xs" color="gray.500" mb={2}>
验证码
</Text>
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="primary.500"
rounded="xl"
overflow="hidden"
>
<HStack alignItems="center" px={4}>
<Input
ref={codeInputRef}
flex={1}
variant="unstyled"
placeholder="请输入6位验证码"
placeholderTextColor="gray.600"
color="white"
fontSize="lg"
fontWeight="bold"
letterSpacing={4}
py={4}
keyboardType="number-pad"
maxLength={6}
value={code}
onChangeText={setCode}
/>
<Pressable
onPress={handleSendCode}
disabled={countdown > 0 || sendingCode}
>
<Text
color={countdown > 0 ? 'gray.500' : 'primary.400'}
fontSize="sm"
fontWeight="semibold"
>
{countdown > 0 ? `${countdown}s` : '重新发送'}
</Text>
</Pressable>
</HStack>
</Box>
</Box>
)}
</VStack>
{/* 操作按钮 */}
<Box mt={8}>
{step === 1 ? (
<Pressable onPress={handleSendCode} disabled={sendingCode}>
<LinearGradient
colors={isValidPhone(phone) ? gradients.primary : ['#475569', '#374151']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.button}
>
{sendingCode ? (
<HStack space={2} alignItems="center">
<Text color="white" fontSize="md" fontWeight="bold">
发送中...
</Text>
</HStack>
) : (
<Text color="white" fontSize="md" fontWeight="bold">
获取验证码
</Text>
)}
</LinearGradient>
</Pressable>
) : (
<Pressable onPress={handleLogin} disabled={loading}>
<LinearGradient
colors={code.length === 6 ? gradients.primary : ['#475569', '#374151']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.button}
>
{loading ? (
<HStack space={2} alignItems="center">
<Text color="white" fontSize="md" fontWeight="bold">
登录中...
</Text>
</HStack>
) : (
<Text color="white" fontSize="md" fontWeight="bold">
登录
</Text>
)}
</LinearGradient>
</Pressable>
)}
</Box>
{/* 底部提示 */}
<Center mt={6}>
<Text fontSize="xs" color="gray.600" textAlign="center">
登录即表示您同意
<Text color="primary.400"> 用户协议</Text>
<Text color="primary.400"> 隐私政策</Text>
</Text>
</Center>
{/* 跳过登录 */}
<Center mt={4}>
<Pressable onPress={() => navigation.goBack()}>
<Text fontSize="sm" color="gray.500">
暂不登录先看看
</Text>
</Pressable>
</Center>
</VStack>
</KeyboardAvoidingView>
</TouchableWithoutFeedback>
</Box>
);
};
const styles = StyleSheet.create({
logoContainer: {
width: 72,
height: 72,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#7C3AED',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 10,
},
button: {
paddingVertical: 16,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#7C3AED',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
});
export default LoginScreen;

View File

@@ -0,0 +1,5 @@
/**
* 认证模块导出
*/
export { default as LoginScreen } from './LoginScreen';

View File

@@ -0,0 +1,344 @@
/**
* 事件卡片组件 - HeroUI 风格
* 玻璃拟态、渐变、现代设计
*/
import React, { memo } from 'react';
import {
Box,
HStack,
VStack,
Text,
Pressable,
Icon,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Dimensions } from 'react-native';
import { gradients, importanceGradients } from '../../theme';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 重要性等级配置
const IMPORTANCE_CONFIG = {
S: {
label: 'S',
gradient: importanceGradients.S,
bgColor: 'rgba(244, 63, 94, 0.12)',
borderColor: 'rgba(244, 63, 94, 0.25)',
textColor: '#F43F5E',
},
A: {
label: 'A',
gradient: importanceGradients.A,
bgColor: 'rgba(245, 158, 11, 0.12)',
borderColor: 'rgba(245, 158, 11, 0.25)',
textColor: '#F59E0B',
},
B: {
label: 'B',
gradient: importanceGradients.B,
bgColor: 'rgba(124, 58, 237, 0.12)',
borderColor: 'rgba(124, 58, 237, 0.25)',
textColor: '#7C3AED',
},
C: {
label: 'C',
gradient: ['#64748B', '#94A3B8'],
bgColor: 'rgba(100, 116, 139, 0.1)',
borderColor: 'rgba(100, 116, 139, 0.2)',
textColor: '#64748B',
},
};
// 格式化涨跌幅(涨红跌绿)
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 getChangeBgColor = (value) => {
if (value === null || value === undefined) return 'rgba(100, 116, 139, 0.1)';
return value >= 0 ? 'rgba(239, 68, 68, 0.12)' : 'rgba(34, 197, 94, 0.12)';
};
// 获取超预期颜色
const getSurpriseColor = (score) => {
if (score >= 80) return { bg: 'rgba(212, 175, 55, 0.15)', text: '#D4AF37', border: 'rgba(212, 175, 55, 0.3)' };
if (score >= 60) return { bg: 'rgba(244, 63, 94, 0.12)', text: '#F43F5E', border: 'rgba(244, 63, 94, 0.25)' };
if (score >= 40) return { bg: 'rgba(251, 146, 60, 0.12)', text: '#FB923C', border: 'rgba(251, 146, 60, 0.25)' };
return { bg: 'rgba(6, 182, 212, 0.12)', text: '#06B6D4', border: 'rgba(6, 182, 212, 0.25)' };
};
const EventCard = memo(({ event, onPress }) => {
const importance = IMPORTANCE_CONFIG[event.importance] || IMPORTANCE_CONFIG.C;
const surpriseScore = event.hot_score || event.expectation_surprise_score || 0;
const surpriseColors = getSurpriseColor(surpriseScore);
// 格式化日期时间
const formatDateTime = (timeString) => {
if (!timeString) return { date: '--', time: '--', weekday: '' };
const date = new Date(timeString);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
let prefix = '';
if (isToday) prefix = '今天';
else if (isYesterday) prefix = '昨天';
return {
date: `${month}-${day}`,
time: `${hour}:${minute}`,
prefix,
};
};
const dateTime = formatDateTime(event.created_at);
return (
<Pressable onPress={onPress}>
{({ isPressed }) => (
<Box
mx={4}
mb={3}
rounded="3xl"
overflow="hidden"
opacity={isPressed ? 0.92 : 1}
style={{ transform: [{ scale: isPressed ? 0.985 : 1 }] }}
>
{/* 玻璃拟态背景 */}
<Box
bg="rgba(30, 41, 59, 0.7)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="3xl"
overflow="hidden"
>
{/* 顶部渐变装饰条 */}
<LinearGradient
colors={importance.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.topBar}
/>
<Box p={4}>
{/* 头部:重要性徽章 + 时间 + 互动数据 */}
<HStack alignItems="center" justifyContent="space-between" mb={3}>
<HStack alignItems="center" space={2}>
{/* 重要性徽章 */}
<LinearGradient
colors={importance.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.importanceBadge}
>
<Text fontSize="xs" fontWeight="bold" color="white">
{importance.label}
</Text>
</LinearGradient>
{/* 时间信息 */}
<VStack>
<HStack alignItems="center" space={1}>
{dateTime.prefix && (
<Text fontSize="2xs" color="secondary.400" fontWeight="semibold">
{dateTime.prefix}
</Text>
)}
<Text fontSize="xs" color="gray.400">
{dateTime.date}
</Text>
<Text fontSize="xs" color="gray.500">
{dateTime.time}
</Text>
</HStack>
</VStack>
</HStack>
{/* 右侧互动数据 */}
<HStack space={3} alignItems="center">
{/* 浏览量 */}
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="eye-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500">
{event.view_count || 0}
</Text>
</HStack>
</HStack>
</HStack>
{/* 标题 */}
<Text
fontSize="md"
fontWeight="bold"
color="white"
numberOfLines={2}
lineHeight="lg"
mb={3}
>
{event.title}
</Text>
{/* 数据指标区域 */}
<HStack space={2} flexWrap="wrap" mb={3}>
{/* 平均超额 */}
<Box
bg={getChangeBgColor(event.related_avg_chg)}
borderWidth={1}
borderColor={event.related_avg_chg >= 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(34, 197, 94, 0.2)'}
rounded="xl"
px={3}
py={2}
flex={1}
minW={20}
>
<Text fontSize="2xs" color="gray.500" mb={0.5}>平均超额</Text>
<HStack alignItems="baseline">
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(event.related_avg_chg)}
>
{formatChange(event.related_avg_chg)}
</Text>
</HStack>
</Box>
{/* 最大超额 */}
<Box
bg={getChangeBgColor(event.related_max_chg)}
borderWidth={1}
borderColor={event.related_max_chg >= 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(34, 197, 94, 0.2)'}
rounded="xl"
px={3}
py={2}
flex={1}
minW={20}
>
<Text fontSize="2xs" color="gray.500" mb={0.5}>最大超额</Text>
<HStack alignItems="baseline">
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(event.related_max_chg)}
>
{formatChange(event.related_max_chg)}
</Text>
</HStack>
</Box>
{/* 超预期得分 */}
<Box
bg={surpriseColors.bg}
borderWidth={1}
borderColor={surpriseColors.border}
rounded="xl"
px={3}
py={2}
flex={1}
minW={20}
>
<Text fontSize="2xs" color="gray.500" mb={0.5}>超预期</Text>
<HStack alignItems="baseline">
<Text
fontSize="lg"
fontWeight="bold"
color={surpriseColors.text}
>
{surpriseScore.toFixed(0)}
</Text>
<Text fontSize="2xs" color="gray.500" ml={0.5}></Text>
</HStack>
</Box>
</HStack>
{/* 底部:看多看空 */}
<HStack justifyContent="space-between" alignItems="center">
<HStack space={2}>
{/* 看多 */}
<Pressable>
<HStack
bg="rgba(239, 68, 68, 0.1)"
borderWidth={1}
borderColor="rgba(239, 68, 68, 0.2)"
rounded="full"
px={3}
py={1.5}
alignItems="center"
space={1.5}
>
<Icon as={Ionicons} name="trending-up" size="xs" color="#EF4444" />
<Text fontSize="xs" fontWeight="semibold" color="#EF4444">
{event.bullish_count || 0}
</Text>
</HStack>
</Pressable>
{/* 看空 */}
<Pressable>
<HStack
bg="rgba(34, 197, 94, 0.1)"
borderWidth={1}
borderColor="rgba(34, 197, 94, 0.2)"
rounded="full"
px={3}
py={1.5}
alignItems="center"
space={1.5}
>
<Icon as={Ionicons} name="trending-down" size="xs" color="#22C55E" />
<Text fontSize="xs" fontWeight="semibold" color="#22C55E">
{event.bearish_count || 0}
</Text>
</HStack>
</Pressable>
</HStack>
{/* 查看详情 */}
<HStack alignItems="center" space={1}>
<Text fontSize="xs" color="gray.500">查看详情</Text>
<Icon as={Ionicons} name="chevron-forward" size="xs" color="gray.500" />
</HStack>
</HStack>
</Box>
</Box>
</Box>
)}
</Pressable>
);
});
const styles = StyleSheet.create({
topBar: {
height: 3,
width: '100%',
},
importanceBadge: {
width: 28,
height: 28,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
});
EventCard.displayName = 'EventCard';
export default EventCard;

View File

@@ -0,0 +1,340 @@
/**
* 事件评论组件 - HeroUI 风格
* 展示事件相关的帖子和评论
*/
import React, { useState, useEffect, useCallback, memo } from 'react';
import { StyleSheet, Keyboard } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
Input,
Avatar,
useToast,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { gradients } from '../../theme';
import eventService from '../../services/eventService';
// 格式化时间
const formatTime = (timeString) => {
if (!timeString) return '';
const date = new Date(timeString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 60) {
return diffMins <= 1 ? '刚刚' : `${diffMins}分钟前`;
} else if (diffHours < 24) {
return `${diffHours}小时前`;
} else if (diffDays < 7) {
return `${diffDays}天前`;
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
};
// 单个评论/帖子项
const CommentItem = memo(({ post, onLike }) => {
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(post.likes || 0);
const handleLike = useCallback(() => {
setLiked(!liked);
setLikeCount(prev => liked ? prev - 1 : prev + 1);
onLike?.(post.id, !liked);
}, [liked, post.id, onLike]);
return (
<Box
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={4}
>
{/* 用户信息 */}
<HStack alignItems="center" mb={3}>
<Avatar
size="sm"
bg="primary.600"
source={post.author?.avatar ? { uri: post.author.avatar } : null}
>
{post.author?.username?.[0]?.toUpperCase() || 'U'}
</Avatar>
<VStack ml={3} flex={1}>
<Text fontSize="sm" fontWeight="semibold" color="white">
{post.author?.username || '匿名用户'}
</Text>
<Text fontSize="2xs" color="gray.500">
{formatTime(post.created_at)}
</Text>
</VStack>
</HStack>
{/* 帖子标题 */}
{post.title && (
<Text fontSize="sm" fontWeight="bold" color="white" mb={2}>
{post.title}
</Text>
)}
{/* 帖子内容 */}
<Text fontSize="sm" color="gray.300" lineHeight="lg" mb={3}>
{post.content}
</Text>
{/* 互动区 */}
<HStack alignItems="center" space={4}>
<Pressable onPress={handleLike}>
<HStack alignItems="center" space={1}>
<Icon
as={Ionicons}
name={liked ? 'heart' : 'heart-outline'}
size="xs"
color={liked ? '#F43F5E' : 'gray.500'}
/>
<Text fontSize="xs" color={liked ? '#F43F5E' : 'gray.500'}>
{likeCount}
</Text>
</HStack>
</Pressable>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="chatbubble-outline" size="xs" color="gray.500" />
<Text fontSize="xs" color="gray.500">
{post.comment_count || 0}
</Text>
</HStack>
</HStack>
</Box>
);
});
CommentItem.displayName = 'CommentItem';
// 事件评论列表组件
const EventComments = ({ eventId, maxDisplay = 5 }) => {
const toast = useToast();
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [showAll, setShowAll] = useState(false);
const [newComment, setNewComment] = useState('');
const [submitting, setSubmitting] = useState(false);
// 加载帖子
useEffect(() => {
const loadPosts = async () => {
setLoading(true);
try {
const response = await eventService.getEventPosts(eventId, {
sort: 'latest',
per_page: 20,
});
if (response.success) {
setPosts(response.data || []);
}
} catch (error) {
console.error('加载帖子失败:', error);
} finally {
setLoading(false);
}
};
loadPosts();
}, [eventId]);
// 提交评论
const handleSubmit = useCallback(async () => {
if (!newComment.trim()) return;
setSubmitting(true);
Keyboard.dismiss();
try {
const response = await eventService.createPost(eventId, {
content: newComment.trim(),
});
if (response.success) {
setPosts(prev => [response.data, ...prev]);
setNewComment('');
toast.show({
description: '发布成功',
placement: 'top',
bg: 'success.500',
});
}
} catch (error) {
toast.show({
description: '发布失败,请稍后重试',
placement: 'top',
bg: 'danger.500',
});
} finally {
setSubmitting(false);
}
}, [eventId, newComment, toast]);
// 点赞处理
const handleLike = useCallback((postId, isLiked) => {
// TODO: 调用点赞 API
console.log('Like post:', postId, isLiked);
}, []);
const displayPosts = showAll ? posts : posts.slice(0, maxDisplay);
const hasMore = posts.length > maxDisplay && !showAll;
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
{/* 标题栏 */}
<HStack alignItems="center" justifyContent="space-between" mb={4}>
<HStack alignItems="center">
<LinearGradient
colors={gradients.purple}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="chatbubbles" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
社区讨论
</Text>
</HStack>
<Box
bg="rgba(139, 92, 246, 0.2)"
borderWidth={1}
borderColor="rgba(139, 92, 246, 0.3)"
rounded="full"
px={2.5}
py={1}
>
<Text fontSize="xs" color="primary.300" fontWeight="semibold">
{posts.length}
</Text>
</Box>
</HStack>
{/* 评论输入框 */}
<Box mb={4}>
<HStack space={2} alignItems="flex-end">
<Input
flex={1}
placeholder="发表你的看法..."
value={newComment}
onChangeText={setNewComment}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
py={2.5}
px={3}
fontSize="sm"
color="white"
placeholderTextColor="gray.500"
multiline
maxH={100}
_focus={{
borderColor: 'primary.500',
bg: 'rgba(124, 58, 237, 0.1)',
}}
/>
<Pressable onPress={handleSubmit} disabled={!newComment.trim() || submitting}>
<LinearGradient
colors={newComment.trim() ? gradients.primary : ['#475569', '#475569']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.sendButton}
>
{submitting ? (
<Spinner size="sm" color="white" />
) : (
<Icon as={Ionicons} name="send" size="sm" color="white" />
)}
</LinearGradient>
</Pressable>
</HStack>
</Box>
{/* 加载状态 */}
{loading ? (
<Center py={8}>
<Spinner size="sm" color="primary.500" />
<Text fontSize="xs" color="gray.500" mt={2}>
加载讨论...
</Text>
</Center>
) : posts.length === 0 ? (
<Center py={8}>
<Icon as={Ionicons} name="chatbubbles-outline" size="3xl" color="gray.600" mb={2} />
<Text fontSize="sm" color="gray.500">
暂无讨论来发表第一条吧
</Text>
</Center>
) : (
<VStack space={3}>
{displayPosts.map((post) => (
<CommentItem key={post.id} post={post} onLike={handleLike} />
))}
</VStack>
)}
{/* 查看更多 */}
{hasMore && (
<Pressable onPress={() => setShowAll(true)} mt={3}>
<HStack
alignItems="center"
justifyContent="center"
py={2.5}
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="xl"
>
<Text fontSize="sm" color="primary.400" mr={1}>
查看全部 {posts.length} 条讨论
</Text>
<Icon as={Ionicons} name="chevron-down" size="xs" color="primary.400" />
</HStack>
</Pressable>
)}
</Box>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
sendButton: {
width: 44,
height: 44,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
});
export default memo(EventComments);

View File

@@ -0,0 +1,610 @@
/**
* 事件详情页面 - HeroUI 风格
* 深色主题 + 渐变 + 玻璃态
*/
import React, { useEffect, useCallback, useState } from 'react';
import { ScrollView, StyleSheet, StatusBar } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Spinner,
Center,
Pressable,
useToast,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSelector, useDispatch } from 'react-redux';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
fetchEventDetail,
toggleEventFollow,
clearCurrentEvent,
} from '../../store/slices/eventsSlice';
import eventService, { stockService } from '../../services/eventService';
import { gradients, importanceGradients } from '../../theme';
import RelatedStocks from './RelatedStocks';
import RelatedConcepts from './RelatedConcepts';
import EventComments from './EventComments';
import HistoricalEvents from './HistoricalEvents';
import TransmissionChain from './TransmissionChain';
import SankeyFlow from './SankeyFlow';
import StockDetailModal from './StockDetailModal';
// 重要性等级配置
const IMPORTANCE_CONFIG = {
S: { label: '重大事件', gradient: importanceGradients.S },
A: { label: '重要事件', gradient: importanceGradients.A },
B: { label: '一般事件', gradient: importanceGradients.B },
C: { label: '普通事件', gradient: importanceGradients.C },
};
const EventDetail = ({ route, navigation }) => {
const { eventId } = route.params;
const dispatch = useDispatch();
const insets = useSafeAreaInsets();
const toast = useToast();
const { currentEvent, loading } = useSelector((state) => state.events);
const [relatedStocks, setRelatedStocks] = useState([]);
const [relatedConcepts, setRelatedConcepts] = useState([]);
const [stockQuotes, setStockQuotes] = useState({}); // 股票报价数据
const [loadingRelated, setLoadingRelated] = useState(false);
const [loadingQuotes, setLoadingQuotes] = useState(false);
const [showAllStocks, setShowAllStocks] = useState(false);
const [showAllConcepts, setShowAllConcepts] = useState(false);
const [userVote, setUserVote] = useState(null); // 'bullish' | 'bearish' | null
const [votingLoading, setVotingLoading] = useState(false);
const [selectedStock, setSelectedStock] = useState(null); // 选中的股票详情
// 加载事件详情
useEffect(() => {
dispatch(fetchEventDetail(eventId));
return () => {
dispatch(clearCurrentEvent());
};
}, [dispatch, eventId]);
// 加载相关数据
useEffect(() => {
const loadRelatedData = async () => {
setLoadingRelated(true);
try {
const [stocksRes, conceptsRes] = await Promise.all([
eventService.getRelatedStocks(eventId),
eventService.getRelatedConcepts(eventId),
]);
let stocksList = [];
if (stocksRes.success) {
stocksList = stocksRes.data || [];
setRelatedStocks(stocksList);
}
if (conceptsRes.success) setRelatedConcepts(conceptsRes.data || []);
// 获取股票报价
if (stocksList.length > 0) {
setLoadingQuotes(true);
try {
const codes = stocksList.map((s) => s.stock_code);
const eventTime = currentEvent?.created_at || null;
const quotes = await stockService.getQuotes(codes, eventTime);
setStockQuotes(quotes || {});
} catch (quoteError) {
console.error('获取股票报价失败:', quoteError);
} finally {
setLoadingQuotes(false);
}
}
} catch (error) {
console.error('加载相关数据失败:', error);
} finally {
setLoadingRelated(false);
}
};
loadRelatedData();
}, [eventId, currentEvent?.created_at]);
// 切换关注
const handleToggleFollow = useCallback(() => {
dispatch(toggleEventFollow(eventId));
toast.show({
description: currentEvent?.is_following ? '已取消关注' : '已关注',
placement: 'top',
bg: currentEvent?.is_following ? 'gray.600' : 'primary.500',
});
}, [dispatch, eventId, currentEvent?.is_following, toast]);
// 情绪投票
const handleVote = useCallback(async (voteType) => {
if (votingLoading) return;
setVotingLoading(true);
try {
// 如果已经是这个投票,则取消
const newVoteType = userVote === voteType ? null : voteType;
const response = await eventService.sentimentVote(eventId, newVoteType);
if (response.success) {
setUserVote(response.data.user_vote);
toast.show({
description: newVoteType
? (newVoteType === 'bullish' ? '看多 +1' : '看空 +1')
: '已取消投票',
placement: 'top',
bg: newVoteType === 'bullish' ? 'success.500' : newVoteType === 'bearish' ? 'danger.500' : 'gray.600',
});
}
} catch (error) {
toast.show({
description: '投票失败,请稍后重试',
placement: 'top',
bg: 'danger.500',
});
} finally {
setVotingLoading(false);
}
}, [eventId, userVote, votingLoading, toast]);
// 点击股票 - 显示股票详情模态框
const handleStockPress = useCallback((stock) => {
setSelectedStock(stock);
}, []);
// 关闭股票详情模态框
const handleCloseStockModal = useCallback(() => {
setSelectedStock(null);
}, []);
// 查看更多股票信息
const handleViewMoreStock = useCallback((stock) => {
setSelectedStock(null);
// TODO: 跳转到股票详情页面
toast.show({
description: `即将查看 ${stock.stock_name || stock.name} 详情`,
placement: 'top',
bg: 'primary.500',
});
}, [toast]);
// 点击概念
const handleConceptPress = useCallback((concept) => {
// TODO: 跳转到概念详情页
toast.show({
description: `${concept.name || concept.concept}`,
placement: 'top',
});
}, [toast]);
// 格式化时间
const formatTime = (timeString) => {
if (!timeString) return '';
const date = new Date(timeString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
// 格式化涨跌幅
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 'gray.500';
return value >= 0 ? '#EF4444' : '#22C55E'; // 涨红跌绿
};
// 加载中状态
if (loading.detail || !currentEvent) {
return (
<Center flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载中...
</Text>
</Center>
);
}
const importance = IMPORTANCE_CONFIG[currentEvent.importance] || IMPORTANCE_CONFIG.C;
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<ScrollView showsVerticalScrollIndicator={false}>
<VStack space={4} pb={insets.bottom + 100} pt={4}>
{/* 头部信息卡片 */}
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
{/* 重要性 + 时间 */}
<HStack justifyContent="space-between" alignItems="center" mb={4}>
<LinearGradient
colors={importance.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.importanceBadge}
>
<Text fontSize="xs" fontWeight="bold" color="white">
{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>
</HStack>
{/* 标题 */}
<Text fontSize="xl" fontWeight="bold" color="white" mb={3} lineHeight="xl">
{currentEvent.title}
</Text>
{/* 描述 */}
{currentEvent.description && (
<Text fontSize="sm" color="gray.400" lineHeight="lg" mb={4}>
{currentEvent.description}
</Text>
)}
{/* 关键词 */}
{currentEvent.keywords && currentEvent.keywords.length > 0 && (
<HStack flexWrap="wrap" space={2}>
{currentEvent.keywords.map((keyword, index) => (
<Box
key={index}
bg="rgba(124, 58, 237, 0.2)"
borderWidth={1}
borderColor="rgba(124, 58, 237, 0.3)"
rounded="full"
px={3}
py={1}
mb={2}
>
<Text fontSize="xs" color="primary.300">
{keyword}
</Text>
</Box>
))}
</HStack>
)}
</Box>
</Box>
{/* 市场影响卡片 */}
<Box mx={4} rounded="3xl" overflow="hidden">
<LinearGradient
colors={['rgba(16, 185, 129, 0.1)', 'rgba(6, 182, 212, 0.1)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cardGradient}
>
<Box
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
<HStack alignItems="center" mb={4}>
<LinearGradient
colors={gradients.success}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="trending-up" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
市场影响
</Text>
</HStack>
<HStack justifyContent="space-around">
<VStack alignItems="center">
<Text
fontSize="2xl"
fontWeight="bold"
color={getChangeColor(currentEvent.related_avg_chg)}
>
{formatChange(currentEvent.related_avg_chg)}
</Text>
<Text fontSize="xs" color="gray.500" mt={1}>
平均涨幅
</Text>
</VStack>
<Box w="1px" h="50px" bg="rgba(255,255,255,0.1)" />
<VStack alignItems="center">
<Text
fontSize="2xl"
fontWeight="bold"
color={getChangeColor(currentEvent.related_max_chg)}
>
{formatChange(currentEvent.related_max_chg)}
</Text>
<Text fontSize="xs" color="gray.500" mt={1}>
最大涨幅
</Text>
</VStack>
<Box w="1px" h="50px" bg="rgba(255,255,255,0.1)" />
<VStack alignItems="center">
<HStack alignItems="center">
<Icon as={Ionicons} name="flame" size="sm" color="#FB923C" mr={1} />
<Text fontSize="2xl" fontWeight="bold" color="#FB923C">
{currentEvent.hot_score?.toFixed(1) || '0.0'}
</Text>
</HStack>
<Text fontSize="xs" color="gray.500" mt={1}>
热度指数
</Text>
</VStack>
</HStack>
</Box>
</LinearGradient>
</Box>
{/* 互动数据卡片 */}
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
<HStack alignItems="center" mb={4}>
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="people" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
互动数据
</Text>
</HStack>
{/* 统计数据 */}
<HStack justifyContent="space-around" mb={4}>
<VStack alignItems="center">
<Text fontSize="xl" fontWeight="bold" color="gray.200">
{currentEvent.view_count || 0}
</Text>
<Text fontSize="xs" color="gray.500">浏览</Text>
</VStack>
<VStack alignItems="center">
<Text fontSize="xl" fontWeight="bold" color="gray.200">
{currentEvent.follower_count || 0}
</Text>
<Text fontSize="xs" color="gray.500">关注</Text>
</VStack>
<VStack alignItems="center">
<Text fontSize="xl" fontWeight="bold" color="#EF4444">
{currentEvent.bullish_count || 0}
</Text>
<Text fontSize="xs" color="gray.500">看多</Text>
</VStack>
<VStack alignItems="center">
<Text fontSize="xl" fontWeight="bold" color="#22C55E">
{currentEvent.bearish_count || 0}
</Text>
<Text fontSize="xs" color="gray.500">看空</Text>
</VStack>
</HStack>
{/* 投票按钮 */}
<Box
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.08)"
pt={4}
>
<Text fontSize="xs" color="gray.500" mb={3} textAlign="center">
您对该事件的看法
</Text>
<HStack space={3}>
<Pressable
flex={1}
onPress={() => handleVote('bullish')}
disabled={votingLoading}
>
<Box
bg={userVote === 'bullish' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
borderWidth={2}
borderColor={userVote === 'bullish' ? '#10B981' : 'rgba(255, 255, 255, 0.1)'}
rounded="xl"
py={3}
alignItems="center"
>
<HStack alignItems="center" space={2}>
<Icon
as={Ionicons}
name={userVote === 'bullish' ? 'trending-up' : 'trending-up-outline'}
size="sm"
color={userVote === 'bullish' ? '#10B981' : 'gray.400'}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={userVote === 'bullish' ? '#10B981' : 'gray.400'}
>
看多
</Text>
</HStack>
</Box>
</Pressable>
<Pressable
flex={1}
onPress={() => handleVote('bearish')}
disabled={votingLoading}
>
<Box
bg={userVote === 'bearish' ? 'rgba(244, 63, 94, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
borderWidth={2}
borderColor={userVote === 'bearish' ? '#F43F5E' : 'rgba(255, 255, 255, 0.1)'}
rounded="xl"
py={3}
alignItems="center"
>
<HStack alignItems="center" space={2}>
<Icon
as={Ionicons}
name={userVote === 'bearish' ? 'trending-down' : 'trending-down-outline'}
size="sm"
color={userVote === 'bearish' ? '#F43F5E' : 'gray.400'}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={userVote === 'bearish' ? '#F43F5E' : 'gray.400'}
>
看空
</Text>
</HStack>
</Box>
</Pressable>
</HStack>
</Box>
</Box>
</Box>
{/* 相关股票 - 放在第一位,最重要 */}
<RelatedStocks
stocks={relatedStocks}
quotes={stockQuotes}
loading={loadingRelated}
loadingQuotes={loadingQuotes}
onStockPress={handleStockPress}
showAll={showAllStocks}
onShowAll={() => setShowAllStocks(true)}
/>
{/* 相关概念 */}
<RelatedConcepts
concepts={relatedConcepts}
loading={loadingRelated}
onConceptPress={handleConceptPress}
showAll={showAllConcepts}
onShowAll={() => setShowAllConcepts(true)}
/>
{/* 传导链分析 */}
<TransmissionChain eventId={eventId} />
{/* 影响流向 */}
<SankeyFlow eventId={eventId} />
{/* 历史事件 */}
<HistoricalEvents
eventId={eventId}
onEventPress={(event) => {
navigation.push('EventDetail', { eventId: event.id, title: event.title });
}}
/>
{/* 社区讨论 */}
<EventComments eventId={eventId} />
</VStack>
</ScrollView>
{/* 底部操作栏 */}
<Box
position="absolute"
bottom={0}
left={0}
right={0}
px={4}
py={4}
pb={insets.bottom + 16}
>
<Pressable onPress={handleToggleFollow}>
<LinearGradient
colors={currentEvent.is_following ? ['#64748B', '#475569'] : gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.followButton}
>
<Icon
as={Ionicons}
name={currentEvent.is_following ? 'heart' : 'heart-outline'}
size="sm"
color="white"
mr={2}
/>
<Text fontSize="md" fontWeight="bold" color="white">
{currentEvent.is_following ? '已关注' : '关注事件'}
</Text>
</LinearGradient>
</Pressable>
</Box>
{/* 股票详情模态框 */}
<StockDetailModal
visible={!!selectedStock}
stock={selectedStock}
quote={selectedStock ? stockQuotes[selectedStock.stock_code] : null}
onClose={handleCloseStockModal}
onViewMore={handleViewMoreStock}
/>
</Box>
);
};
const styles = StyleSheet.create({
importanceBadge: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 20,
},
cardGradient: {
borderRadius: 24,
},
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
followButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
borderRadius: 24,
shadowColor: '#7C3AED',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
});
export default EventDetail;

View File

@@ -0,0 +1,660 @@
/**
* 事件列表页面 - HeroUI 风格
* 玻璃拟态、渐变、现代设计
* 支持列表和题材两种视图模式
*/
import React, { useEffect, useCallback, useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, StatusBar, ScrollView, Dimensions } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Pressable,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSelector, useDispatch } from 'react-redux';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import EventCard from './EventCard';
import MainlineView from './MainlineView';
import { gradients, importanceGradients } from '../../theme';
import {
fetchEvents,
setFilters,
} from '../../store/slices/eventsSlice';
import tradingDayUtils from '../../utils/tradingDayUtils';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 视图模式
const VIEW_MODES = {
LIST: 'list',
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: '当前交易日' },
{ key: 'today', label: '今日' },
{ key: '7', label: '近一周' },
{ key: '30', label: '近一月' },
{ key: '', label: '全部' },
];
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 {
events,
pagination,
loading,
refreshing,
} = useSelector((state) => state.events);
// 初始加载 - 设置默认的当前交易日筛选
useEffect(() => {
if (viewMode === VIEW_MODES.LIST) {
// 设置默认的当前交易日时间范围
const { startDate, endDate } = tradingDayUtils.getCurrentTradingDayRange();
dispatch(setFilters({
start_date: startDate,
end_date: endDate,
recent_days: '',
}));
dispatch(fetchEvents({ page: 1, refresh: true }));
}
}, [dispatch, viewMode]);
// 下拉刷新 - 保持当前的时间筛选
const handleRefresh = useCallback(() => {
// 如果当前是"当前交易日",需要更新时间范围(因为"现在"变了)
if (activeTime === 'current-trading-day') {
const { startDate, endDate } = tradingDayUtils.getCurrentTradingDayRange();
dispatch(setFilters({
start_date: startDate,
end_date: endDate,
recent_days: '',
}));
}
dispatch(fetchEvents({ page: 1, refresh: true }));
}, [dispatch, activeTime]);
// 加载更多
const handleLoadMore = useCallback(() => {
if (loading.events || !pagination.has_next) return;
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);
let filterUpdate = { start_date: '', end_date: '', recent_days: '' };
if (timeKey === 'current-trading-day') {
// 当前交易日上一个交易日15:00 到 现在
const { startDate, endDate } = tradingDayUtils.getCurrentTradingDayRange();
filterUpdate = { start_date: startDate, end_date: endDate, recent_days: '' };
} else if (timeKey === 'today') {
// 今日全天
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
filterUpdate = {
start_date: tradingDayUtils.formatDateTime(todayStart),
end_date: tradingDayUtils.formatDateTime(todayEnd),
recent_days: '',
};
} else if (timeKey === '7' || timeKey === '30') {
// 使用 recent_days
filterUpdate = { start_date: '', end_date: '', recent_days: timeKey };
} else {
// 全部(空)
filterUpdate = { start_date: '', end_date: '', recent_days: '' };
}
dispatch(setFilters(filterUpdate));
dispatch(fetchEvents({ page: 1, refresh: true }));
}, [dispatch]);
// 点击事件
const handleEventPress = useCallback(
(event) => {
navigation.navigate('EventDetail', { eventId: event.id, title: event.title });
},
[navigation]
);
// 切换视图模式
const handleModeChange = useCallback((mode) => {
setViewMode(mode);
}, []);
// 渲染模式切换按钮
const renderModeToggle = () => (
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="2xl"
p={1}
overflow="hidden"
>
<HStack space={1}>
<Pressable onPress={() => handleModeChange(VIEW_MODES.LIST)}>
{({ isPressed }) => (
viewMode === VIEW_MODES.LIST ? (
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.modeButtonActive}
>
<Icon as={Ionicons} name="list" size="sm" color="white" />
<Text fontSize="xs" fontWeight="bold" color="white" ml={1.5}>
列表
</Text>
</LinearGradient>
) : (
<Box
style={styles.modeButton}
opacity={isPressed ? 0.7 : 1}
>
<Icon as={Ionicons} name="list" size="sm" color="gray.400" />
<Text fontSize="xs" color="gray.400" ml={1.5}>
列表
</Text>
</Box>
)
)}
</Pressable>
<Pressable onPress={() => handleModeChange(VIEW_MODES.MAINLINE)}>
{({ isPressed }) => (
viewMode === VIEW_MODES.MAINLINE ? (
<LinearGradient
colors={gradients.secondary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.modeButtonActive}
>
<Icon as={Ionicons} name="grid" size="sm" color="white" />
<Text fontSize="xs" fontWeight="bold" color="white" ml={1.5}>
题材
</Text>
</LinearGradient>
) : (
<Box
style={styles.modeButton}
opacity={isPressed ? 0.7 : 1}
>
<Icon as={Ionicons} name="grid" size="sm" color="gray.400" />
<Text fontSize="xs" color="gray.400" ml={1.5}>
题材
</Text>
</Box>
)
)}
</Pressable>
</HStack>
</Box>
);
// 渲染头部
const renderHeader = () => (
<VStack>
{/* 头部背景渐变装饰 */}
<Box position="absolute" top={0} left={0} right={0} h={200} overflow="hidden">
<LinearGradient
colors={['rgba(124, 58, 237, 0.15)', 'rgba(6, 182, 212, 0.08)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
</Box>
{/* 标题区域 */}
<Box px={4} pt={3} pb={4}>
<HStack alignItems="center" justifyContent="space-between">
<VStack flex={1} space={1}>
<HStack alignItems="center" space={2}>
{/* 标题图标 */}
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.titleIcon}
>
<Icon as={Ionicons} name="flash" size="sm" color="white" />
</LinearGradient>
<Text fontSize="2xl" fontWeight="bold" color="white" letterSpacing="sm">
事件中心
</Text>
</HStack>
<Text fontSize="sm" color="gray.400" ml={10}>
发现市场热点把握投资机会
</Text>
</VStack>
<HStack space={3} alignItems="center">
{/* 模式切换 */}
{renderModeToggle()}
{/* 菜单按钮 */}
<Pressable
onPress={() => navigation.openDrawer?.()}
>
{({ isPressed }) => (
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
p={2.5}
opacity={isPressed ? 0.7 : 1}
>
<Icon as={Ionicons} name="menu" size="md" color="white" />
</Box>
)}
</Pressable>
</HStack>
</HStack>
</Box>
{/* 列表模式的筛选标签 */}
{viewMode === VIEW_MODES.LIST && (
<>
{/* 筛选标签区域 - 玻璃卡片 */}
<Box mx={4} mb={3}>
<Box
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="2xl"
overflow="hidden"
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
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;
return (
<Pressable
key={option.key}
onPress={() => handleTimeChange(option.key)}
>
{({ isPressed }) => (
isActive ? (
<LinearGradient
colors={gradients.secondary}
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={3}
py={2}
opacity={isPressed ? 0.7 : 1}
>
<Text fontSize="xs" color="gray.400">
{option.label}
</Text>
</Box>
)
)}
</Pressable>
);
})}
</HStack>
</ScrollView>
</Box>
</Box>
{/* 事件统计栏 */}
<HStack
mx={4}
mb={3}
px={4}
py={3}
bg="rgba(30, 41, 59, 0.5)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.06)"
rounded="xl"
alignItems="center"
justifyContent="space-between"
>
<HStack alignItems="center" space={2}>
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="newspaper" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white">
最新事件
</Text>
</HStack>
<Box
bg="rgba(124, 58, 237, 0.15)"
borderWidth={1}
borderColor="rgba(124, 58, 237, 0.25)"
rounded="lg"
px={3}
py={1}
>
<Text fontSize="xs" fontWeight="semibold" color="primary.400">
{pagination.total || 0}
</Text>
</Box>
</HStack>
</>
)}
</VStack>
);
// 渲染加载更多
const renderFooter = () => {
if (!loading.events || pagination.page === 1) return null;
return (
<Center py={8}>
<Box
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="2xl"
px={6}
py={4}
>
<HStack alignItems="center" space={3}>
<Spinner size="sm" color="primary.400" />
<Text fontSize="sm" color="gray.400">
加载更多事件...
</Text>
</HStack>
</Box>
</Center>
);
};
// 渲染空状态
const renderEmpty = () => {
if (loading.events && pagination.page === 1) {
return (
<Center flex={1} py={20}>
<Box
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="3xl"
p={8}
alignItems="center"
>
<Box mb={4}>
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.loadingIcon}
>
<Spinner size="lg" color="white" />
</LinearGradient>
</Box>
<Text fontSize="md" fontWeight="semibold" color="white" mb={1}>
正在加载事件
</Text>
<Text fontSize="sm" color="gray.500">
请稍候...
</Text>
</Box>
</Center>
);
}
return (
<Center flex={1} py={20}>
<Box
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="3xl"
p={8}
alignItems="center"
>
<Box
bg="rgba(100, 116, 139, 0.2)"
borderWidth={1}
borderColor="rgba(100, 116, 139, 0.3)"
rounded="full"
p={5}
mb={4}
>
<Icon as={Ionicons} name="newspaper-outline" size="3xl" color="gray.500" />
</Box>
<Text fontSize="lg" fontWeight="semibold" color="white" mb={1}>
暂无事件
</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
当前筛选条件下没有找到事件
</Text>
<Pressable
onPress={handleRefresh}
mt={4}
>
{({ isPressed }) => (
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.refreshButton, { opacity: isPressed ? 0.8 : 1 }]}
>
<Icon as={Ionicons} name="refresh" size="xs" color="white" />
<Text fontSize="sm" fontWeight="bold" color="white" ml={2}>
刷新试试
</Text>
</LinearGradient>
)}
</Pressable>
</Box>
</Center>
);
};
// 渲染事件卡片
const renderItem = useCallback(
({ item }) => <EventCard event={item} onPress={() => handleEventPress(item)} />,
[handleEventPress]
);
const keyExtractor = useCallback((item) => `event-${item.id}`, []);
// 题材视图模式
if (viewMode === VIEW_MODES.MAINLINE) {
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<VStack style={{ paddingTop: insets.top }}>
{renderHeader()}
</VStack>
<MainlineView navigation={navigation} />
</Box>
);
}
// 列表视图模式
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<FlatList
data={events}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={renderHeader}
ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: insets.bottom + 20,
}}
/>
</Box>
);
};
const styles = StyleSheet.create({
titleIcon: {
width: 32,
height: 32,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
iconBadge: {
width: 28,
height: 28,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
filterBadgeActive: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
modeButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 12,
},
modeButtonActive: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 12,
},
loadingIcon: {
width: 64,
height: 64,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
refreshButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 14,
},
});
export default EventList;

View File

@@ -0,0 +1,308 @@
/**
* 事件筛选弹窗组件 - HeroUI 风格
*/
import React, { useState, useCallback } from 'react';
import { StyleSheet, ScrollView } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Modal,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { gradients } from '../../theme';
// 重要性选项
const IMPORTANCE_OPTIONS = [
{ key: 'S', label: '重大', gradient: ['#F43F5E', '#EC4899'] },
{ key: 'A', label: '重要', gradient: ['#F59E0B', '#FBBF24'] },
{ key: 'B', label: '一般', gradient: ['#7C3AED', '#8B5CF6'] },
{ key: 'C', label: '普通', gradient: ['#64748B', '#94A3B8'] },
];
// 时间范围选项
const DATE_RANGE_OPTIONS = [
{ key: '', label: '全部', days: 0 },
{ key: '1', label: '今天', days: 1 },
{ key: '3', label: '3天内', days: 3 },
{ key: '7', label: '一周内', days: 7 },
{ key: '30', label: '一月内', days: 30 },
];
const FilterModal = ({ isOpen, onClose, filters, onApply }) => {
// 本地筛选状态
const [localFilters, setLocalFilters] = useState({
importance: filters.importance || '',
recent_days: filters.recent_days || '',
});
// 重置为传入的 filters
React.useEffect(() => {
if (isOpen) {
setLocalFilters({
importance: filters.importance || '',
recent_days: filters.recent_days || '',
});
}
}, [isOpen, filters]);
// 切换重要性选择(支持多选)
const toggleImportance = useCallback((key) => {
setLocalFilters(prev => {
const current = prev.importance ? prev.importance.split(',') : [];
const index = current.indexOf(key);
if (index > -1) {
current.splice(index, 1);
} else {
current.push(key);
}
return { ...prev, importance: current.join(',') };
});
}, []);
// 选择时间范围
const selectDateRange = useCallback((key) => {
setLocalFilters(prev => ({ ...prev, recent_days: key }));
}, []);
// 重置筛选
const handleReset = useCallback(() => {
setLocalFilters({ importance: '', recent_days: '' });
}, []);
// 应用筛选
const handleApply = useCallback(() => {
onApply(localFilters);
onClose();
}, [localFilters, onApply, onClose]);
// 检查是否选中重要性
const isImportanceSelected = (key) => {
const selected = localFilters.importance ? localFilters.importance.split(',') : [];
return selected.includes(key);
};
// 统计已选筛选数量
const getFilterCount = () => {
let count = 0;
if (localFilters.importance) count += localFilters.importance.split(',').length;
if (localFilters.recent_days) count += 1;
return count;
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="full">
<Modal.Content
bg="#0F172A"
maxH="80%"
marginTop="auto"
marginBottom={0}
borderTopRadius="3xl"
borderBottomRadius={0}
>
{/* 头部 */}
<Box px={5} py={4} borderBottomWidth={1} borderBottomColor="rgba(255,255,255,0.1)">
<HStack justifyContent="space-between" alignItems="center">
<HStack alignItems="center" space={2}>
<Text fontSize="lg" fontWeight="bold" color="white">
筛选条件
</Text>
{getFilterCount() > 0 && (
<Box bg="primary.500" rounded="full" px={2} py={0.5}>
<Text fontSize="xs" color="white" fontWeight="bold">
{getFilterCount()}
</Text>
</Box>
)}
</HStack>
<Pressable onPress={onClose} p={2}>
<Icon as={Ionicons} name="close" size="md" color="gray.400" />
</Pressable>
</HStack>
</Box>
{/* 筛选内容 */}
<ScrollView style={{ flex: 1 }}>
<VStack space={6} px={5} py={5}>
{/* 重要性筛选 */}
<VStack space={3}>
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="star" size="sm" color="primary.400" />
<Text fontSize="md" fontWeight="semibold" color="white">
重要性等级
</Text>
<Text fontSize="xs" color="gray.500">
(可多选)
</Text>
</HStack>
<HStack space={3} flexWrap="wrap">
{IMPORTANCE_OPTIONS.map((option) => {
const isSelected = isImportanceSelected(option.key);
return (
<Pressable
key={option.key}
onPress={() => toggleImportance(option.key)}
mb={2}
>
{isSelected ? (
<LinearGradient
colors={option.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.importanceBadge}
>
<Text fontSize="sm" fontWeight="bold" color="white">
{option.key} · {option.label}
</Text>
<Icon as={Ionicons} name="checkmark" size="xs" color="white" ml={1} />
</LinearGradient>
) : (
<Box
bg="rgba(255,255,255,0.05)"
borderWidth={1}
borderColor="rgba(255,255,255,0.1)"
rounded="xl"
px={4}
py={2}
>
<Text fontSize="sm" color="gray.400">
{option.key} · {option.label}
</Text>
</Box>
)}
</Pressable>
);
})}
</HStack>
</VStack>
{/* 时间范围筛选 */}
<VStack space={3}>
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="calendar" size="sm" color="secondary.400" />
<Text fontSize="md" fontWeight="semibold" color="white">
时间范围
</Text>
</HStack>
<HStack space={2} flexWrap="wrap">
{DATE_RANGE_OPTIONS.map((option) => {
const isSelected = localFilters.recent_days === option.key;
return (
<Pressable
key={option.key}
onPress={() => selectDateRange(option.key)}
mb={2}
>
{isSelected ? (
<LinearGradient
colors={gradients.secondary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.dateRangeBadge}
>
<Text fontSize="sm" fontWeight="bold" color="white">
{option.label}
</Text>
</LinearGradient>
) : (
<Box
bg="rgba(255,255,255,0.05)"
borderWidth={1}
borderColor="rgba(255,255,255,0.1)"
rounded="xl"
px={4}
py={2}
>
<Text fontSize="sm" color="gray.400">
{option.label}
</Text>
</Box>
)}
</Pressable>
);
})}
</HStack>
</VStack>
</VStack>
</ScrollView>
{/* 底部按钮 */}
<Box
px={5}
py={4}
borderTopWidth={1}
borderTopColor="rgba(255,255,255,0.1)"
bg="rgba(15, 23, 42, 0.95)"
>
<HStack space={3}>
{/* 重置按钮 */}
<Pressable flex={1} onPress={handleReset}>
<Box
bg="rgba(255,255,255,0.05)"
borderWidth={1}
borderColor="rgba(255,255,255,0.1)"
rounded="2xl"
py={3.5}
alignItems="center"
>
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="refresh" size="sm" color="gray.400" />
<Text fontSize="md" fontWeight="semibold" color="gray.400">
重置
</Text>
</HStack>
</Box>
</Pressable>
{/* 确认按钮 */}
<Pressable flex={2} onPress={handleApply}>
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.applyButton}
>
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="checkmark-circle" size="sm" color="white" />
<Text fontSize="md" fontWeight="bold" color="white">
应用筛选
</Text>
</HStack>
</LinearGradient>
</Pressable>
</HStack>
</Box>
</Modal.Content>
</Modal>
);
};
const styles = StyleSheet.create({
importanceBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
dateRangeBadge: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
applyButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 16,
},
});
export default FilterModal;

View File

@@ -0,0 +1,286 @@
/**
* 历史事件时间线组件 - HeroUI 风格
* 展示与当前事件相关的历史事件
*/
import React, { useState, useEffect, memo } from 'react';
import { StyleSheet } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { gradients } from '../../theme';
import eventService from '../../services/eventService';
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
// 获取相关性颜色
const getRelevanceColor = (relevance) => {
if (relevance >= 0.8) return '#10B981';
if (relevance >= 0.5) return '#FBBF24';
return '#64748B';
};
// 获取相关性标签
const getRelevanceLabel = (relevance) => {
if (relevance >= 0.8) return '高度相关';
if (relevance >= 0.5) return '中度相关';
return '低度相关';
};
// 单个历史事件项
const HistoricalEventItem = memo(({ event, index, isLast, onPress }) => {
const relevanceColor = getRelevanceColor(event.relevance);
return (
<HStack>
{/* 时间线 */}
<VStack alignItems="center" w={8} mr={3}>
{/* 节点 */}
<Box
w={4}
h={4}
rounded="full"
bg={relevanceColor}
borderWidth={3}
borderColor={`${relevanceColor}40`}
shadow={2}
/>
{/* 连接线 */}
{!isLast && (
<Box
flex={1}
w={0.5}
bg="rgba(255, 255, 255, 0.1)"
minH={20}
/>
)}
</VStack>
{/* 内容 */}
<Pressable flex={1} onPress={() => onPress?.(event)} _pressed={{ opacity: 0.7 }}>
<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}>
<HStack alignItems="center" space={1}>
<Icon as={Ionicons} name="calendar-outline" size="2xs" color="gray.500" />
<Text fontSize="xs" color="gray.500">
{formatDate(event.event_date)}
</Text>
</HStack>
<Box
bg={`${relevanceColor}20`}
borderWidth={1}
borderColor={`${relevanceColor}40`}
rounded="full"
px={2}
py={0.5}
>
<Text fontSize="2xs" color={relevanceColor} fontWeight="semibold">
{getRelevanceLabel(event.relevance)}
</Text>
</Box>
</HStack>
{/* 标题 */}
<Text fontSize="sm" fontWeight="bold" color="white" mb={2} numberOfLines={2}>
{event.title}
</Text>
{/* 内容摘要 */}
{event.content && (
<Text fontSize="xs" color="gray.400" numberOfLines={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>
)}
</HStack>
)}
</Box>
</Pressable>
</HStack>
);
});
HistoricalEventItem.displayName = 'HistoricalEventItem';
// 历史事件组件
const HistoricalEvents = ({ eventId, onEventPress, maxDisplay = 5 }) => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [showAll, setShowAll] = useState(false);
// 加载历史事件
useEffect(() => {
const loadEvents = async () => {
setLoading(true);
try {
const response = await eventService.getHistoricalEvents(eventId);
if (response.success) {
// 按日期排序
const sorted = (response.data || []).sort(
(a, b) => new Date(b.event_date) - new Date(a.event_date)
);
setEvents(sorted);
}
} catch (error) {
console.error('加载历史事件失败:', error);
} finally {
setLoading(false);
}
};
loadEvents();
}, [eventId]);
// 空状态不显示
if (!loading && events.length === 0) {
return null;
}
const displayEvents = showAll ? events : events.slice(0, maxDisplay);
const hasMore = events.length > maxDisplay && !showAll;
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
{/* 标题栏 */}
<HStack alignItems="center" justifyContent="space-between" mb={4}>
<HStack alignItems="center">
<LinearGradient
colors={gradients.sunset}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="time" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
历史事件
</Text>
</HStack>
{events.length > 0 && (
<Box
bg="rgba(244, 63, 94, 0.2)"
borderWidth={1}
borderColor="rgba(244, 63, 94, 0.3)"
rounded="full"
px={2.5}
py={1}
>
<Text fontSize="xs" color="danger.400" fontWeight="semibold">
{events.length}
</Text>
</Box>
)}
</HStack>
{/* 加载状态 */}
{loading ? (
<Center py={8}>
<Spinner size="sm" color="primary.500" />
<Text fontSize="xs" color="gray.500" mt={2}>
加载历史事件...
</Text>
</Center>
) : (
<VStack>
{displayEvents.map((event, index) => (
<HistoricalEventItem
key={event.id || index}
event={event}
index={index}
isLast={index === displayEvents.length - 1}
onPress={onEventPress}
/>
))}
</VStack>
)}
{/* 查看更多 */}
{hasMore && (
<Pressable onPress={() => setShowAll(true)} mt={3}>
<HStack
alignItems="center"
justifyContent="center"
py={2.5}
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="xl"
>
<Text fontSize="sm" color="danger.400" mr={1}>
查看全部 {events.length} 条历史事件
</Text>
<Icon as={Ionicons} name="chevron-down" size="xs" color="danger.400" />
</HStack>
</Pressable>
)}
</Box>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
});
export default memo(HistoricalEvents);

View File

@@ -0,0 +1,701 @@
/**
* 主线/题材视图组件
* 按题材分类展示事件
* 支持当前交易日筛选
*/
import React, { useEffect, useCallback, useState, useMemo, memo } from 'react';
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Pressable,
ScrollView,
Badge,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSelector, useDispatch } from 'react-redux';
import { fetchMainlineEvents, setMainlineGroupBy } from '../../store/slices/eventsSlice';
import { gradients } from '../../theme';
import tradingDayUtils from '../../utils/tradingDayUtils';
// 分组级别选项
const GROUP_OPTIONS = [
{ key: 'lv1', label: '一级' },
{ key: 'lv2', label: '二级' },
{ key: 'lv3', label: '三级' },
];
// 时间范围选项
const TIME_OPTIONS = [
{ key: 'current-trading-day', label: '当前交易日' },
{ key: 'today', label: '今日' },
{ key: '7', label: '近一周' },
{ key: '30', label: '近一月' },
];
// 排序选项
const SORT_OPTIONS = [
{ key: 'event_count', label: '事件数' },
{ key: 'change_desc', label: '涨幅↓' },
{ key: 'change_asc', label: '跌幅↓' },
];
// 格式化涨跌幅
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
};
// 获取涨跌颜色(涨红跌绿)
const getChangeColor = (value) => {
if (value === null || value === undefined || isNaN(value)) return '#64748B';
return value >= 0 ? '#EF4444' : '#22C55E';
};
// 获取涨跌背景色
const getChangeBgColor = (value) => {
if (value === null || value === undefined || isNaN(value)) return 'transparent';
const absChange = Math.abs(value);
if (value > 0) {
if (absChange >= 5) return 'rgba(239, 68, 68, 0.12)';
if (absChange >= 3) return 'rgba(239, 68, 68, 0.08)';
return 'rgba(239, 68, 68, 0.05)';
} else if (value < 0) {
if (absChange >= 5) return 'rgba(34, 197, 94, 0.12)';
if (absChange >= 3) return 'rgba(34, 197, 94, 0.08)';
return 'rgba(34, 197, 94, 0.05)';
}
return 'transparent';
};
// 格式化时间
const formatEventTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
if (isToday) {
return `今天 ${month}-${day} ${hour}:${minute}`;
} else if (isYesterday) {
return `昨天 ${month}-${day} ${hour}:${minute}`;
}
return `${month}-${day} ${hour}:${minute}`;
};
// 单个事件项组件
const EventItem = memo(({ event, onPress, isHot }) => {
const maxChange = event.related_max_chg;
const avgChange = event.related_avg_chg;
const hasMaxChange = maxChange != null && !isNaN(maxChange);
const hasAvgChange = avgChange != null && !isNaN(avgChange);
return (
<Pressable onPress={() => onPress?.(event)}>
{({ isPressed }) => (
<Box
bg={isHot ? 'rgba(239, 68, 68, 0.08)' : getChangeBgColor(avgChange)}
borderWidth={1}
borderColor={isHot ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 255, 255, 0.05)'}
rounded="xl"
p={3}
mb={2}
opacity={isPressed ? 0.8 : 1}
>
{/* 时间 */}
<Text fontSize="2xs" color="gray.500" mb={1}>
{formatEventTime(event.created_at || event.event_time)}
</Text>
{/* 标题 */}
<HStack alignItems="flex-start" space={2} mb={2}>
{isHot && (
<HStack
bg="rgba(239, 68, 68, 0.2)"
rounded="md"
px={1.5}
py={0.5}
alignItems="center"
space={0.5}
>
<Icon as={Ionicons} name="flame" size="2xs" color="#EF4444" />
<Text fontSize="2xs" fontWeight="bold" color="#EF4444">
HOT
</Text>
</HStack>
)}
<Text fontSize="sm" color="white" fontWeight="medium" numberOfLines={2} flex={1}>
{event.title}
</Text>
</HStack>
{/* 涨跌幅指标 */}
{(hasMaxChange || hasAvgChange) && (
<HStack space={2} flexWrap="wrap">
{hasMaxChange && (
<Box
bg={maxChange > 0 ? 'rgba(239, 68, 68, 0.15)' : 'rgba(34, 197, 94, 0.15)'}
borderWidth={1}
borderColor={maxChange > 0 ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)'}
rounded="md"
px={2}
py={1}
>
<Text fontSize="2xs" color="gray.500">最大超额</Text>
<Text fontSize="xs" fontWeight="bold" color={getChangeColor(maxChange)}>
{formatChange(maxChange)}
</Text>
</Box>
)}
{hasAvgChange && (
<Box
bg={avgChange > 0 ? 'rgba(239, 68, 68, 0.15)' : 'rgba(34, 197, 94, 0.15)'}
borderWidth={1}
borderColor={avgChange > 0 ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)'}
rounded="md"
px={2}
py={1}
>
<Text fontSize="2xs" color="gray.500">平均超额</Text>
<Text fontSize="xs" fontWeight="bold" color={getChangeColor(avgChange)}>
{formatChange(avgChange)}
</Text>
</Box>
)}
{event.expectation_surprise_score != null && (
<Box
bg={event.expectation_surprise_score >= 60 ? 'rgba(239, 68, 68, 0.15)' :
event.expectation_surprise_score >= 40 ? 'rgba(251, 146, 60, 0.15)' : 'rgba(6, 182, 212, 0.15)'}
borderWidth={1}
borderColor={event.expectation_surprise_score >= 60 ? 'rgba(239, 68, 68, 0.3)' :
event.expectation_surprise_score >= 40 ? 'rgba(251, 146, 60, 0.3)' : 'rgba(6, 182, 212, 0.3)'}
rounded="md"
px={2}
py={1}
>
<Text fontSize="2xs" color="gray.500">超预期</Text>
<Text
fontSize="xs"
fontWeight="bold"
color={event.expectation_surprise_score >= 60 ? '#EF4444' :
event.expectation_surprise_score >= 40 ? '#FB923C' : '#06B6D4'}
>
{Math.round(event.expectation_surprise_score)}
</Text>
</Box>
)}
</HStack>
)}
</Box>
)}
</Pressable>
);
});
// 主线卡片组件
const MainlineCard = memo(({ item, onEventPress }) => {
const [expanded, setExpanded] = useState(false);
const events = item.events || [];
const hasEvents = events.length > 0;
// 找出 HOT 事件(最大超额涨幅最高的)
const hotEvent = useMemo(() => {
if (!hasEvents) return null;
let maxChange = -Infinity;
let hot = null;
events.forEach((event) => {
const change = event.related_max_chg ?? -Infinity;
if (change > maxChange) {
maxChange = change;
hot = event;
}
});
return maxChange > 0 ? hot : null;
}, [events, hasEvents]);
// 显示的事件列表
const displayedEvents = expanded ? events : events.slice(0, 3);
const groupName = item.group_name || item.lv2_name || item.lv1_name || '其他';
return (
<Box mx={4} mb={3}>
<Box
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
overflow="hidden"
>
{/* 头部:题材名称和统计 */}
<Pressable onPress={() => hasEvents && setExpanded(!expanded)}>
{({ isPressed }) => (
<Box
px={4}
py={3}
bg={isPressed ? 'rgba(255, 255, 255, 0.03)' : 'transparent'}
>
<HStack alignItems="center" justifyContent="space-between">
<VStack flex={1} space={0.5}>
<HStack alignItems="center" space={2}>
<Text fontSize="md" fontWeight="bold" color="white" numberOfLines={1} flex={1}>
{groupName}
</Text>
{/* 涨跌幅 */}
{item.avg_change_pct != null && (
<Text
fontSize="sm"
fontWeight="bold"
color={getChangeColor(item.avg_change_pct)}
>
{formatChange(item.avg_change_pct)}
</Text>
)}
{/* 事件数徽章 */}
<Box
bg="rgba(124, 58, 237, 0.2)"
rounded="full"
px={2}
py={0.5}
>
<Text fontSize="2xs" fontWeight="bold" color="primary.400">
{item.event_count || events.length}
</Text>
</Box>
</HStack>
{/* 上级概念名称 */}
{(item.parent_name || item.grandparent_name) && (
<Text fontSize="2xs" color="gray.500" numberOfLines={1}>
{item.grandparent_name ? `${item.grandparent_name} > ` : ''}
{item.parent_name || ''}
</Text>
)}
</VStack>
{hasEvents && (
<Icon
as={Ionicons}
name={expanded ? 'chevron-up' : 'chevron-down'}
size="sm"
color="gray.500"
ml={2}
/>
)}
</HStack>
</Box>
)}
</Pressable>
{/* HOT 事件展示(未展开时显示) */}
{!expanded && hotEvent && (
<Pressable onPress={() => onEventPress?.(hotEvent)}>
{({ isPressed }) => (
<Box
px={4}
py={3}
bg={isPressed ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.08)'}
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.05)"
>
<HStack alignItems="center" space={2} mb={1}>
<HStack
bg="rgba(239, 68, 68, 0.2)"
rounded="md"
px={1.5}
py={0.5}
alignItems="center"
space={0.5}
>
<Icon as={Ionicons} name="flame" size="2xs" color="#EF4444" />
<Text fontSize="2xs" fontWeight="bold" color="#EF4444">
HOT
</Text>
</HStack>
{hotEvent.related_max_chg != null && (
<Box bg="rgba(239, 68, 68, 0.2)" rounded="md" px={2} py={0.5}>
<Text fontSize="2xs" color="#EF4444" fontWeight="bold">
最大超额 {formatChange(hotEvent.related_max_chg)}
</Text>
</Box>
)}
</HStack>
<Text fontSize="sm" color="white" fontWeight="medium" numberOfLines={2}>
{hotEvent.title}
</Text>
</Box>
)}
</Pressable>
)}
{/* 事件列表 */}
{hasEvents && (
<Box px={3} py={2}>
{displayedEvents.map((event, index) => (
<EventItem
key={event.id || index}
event={event}
onPress={onEventPress}
isHot={expanded && hotEvent && event.id === hotEvent.id}
/>
))}
{/* 展开/收起提示 */}
{events.length > 3 && (
<Pressable onPress={() => setExpanded(!expanded)}>
<Center py={2}>
<Text fontSize="xs" color="gray.500">
{expanded
? '收起'
: `... 还有 ${events.length - 3} 条,点击展开`}
</Text>
</Center>
</Pressable>
)}
</Box>
)}
</Box>
</Box>
);
});
const MainlineView = ({ navigation }) => {
const dispatch = useDispatch();
const {
mainlineData,
mainlineGroupBy,
loading,
} = useSelector((state) => state.events);
const [refreshing, setRefreshing] = useState(false);
const [sortBy, setSortBy] = useState('event_count');
const [activeTime, setActiveTime] = useState('current-trading-day'); // 默认当前交易日
// 获取时间范围参数
const getTimeParams = useCallback((timeKey) => {
if (timeKey === 'current-trading-day') {
// 当前交易日上一个交易日15:00 到 现在
const { startDate, endDate } = tradingDayUtils.getCurrentTradingDayRange();
return { startDate, endDate };
} else if (timeKey === 'today') {
// 今日全天
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
return {
startDate: tradingDayUtils.formatDateTime(todayStart),
endDate: tradingDayUtils.formatDateTime(todayEnd),
};
} else if (timeKey === '7' || timeKey === '30') {
// 使用 recentDays
return { recentDays: parseInt(timeKey, 10) };
}
return {};
}, []);
// 初始加载 - 使用当前交易日
useEffect(() => {
const timeParams = getTimeParams(activeTime);
dispatch(fetchMainlineEvents({ groupBy: mainlineGroupBy, ...timeParams }));
}, [dispatch, mainlineGroupBy, activeTime, getTimeParams]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setRefreshing(true);
const timeParams = getTimeParams(activeTime);
await dispatch(fetchMainlineEvents({ groupBy: mainlineGroupBy, ...timeParams }));
setRefreshing(false);
}, [dispatch, mainlineGroupBy, activeTime, getTimeParams]);
// 切换分组级别
const handleGroupByChange = useCallback((groupBy) => {
dispatch(setMainlineGroupBy(groupBy));
const timeParams = getTimeParams(activeTime);
dispatch(fetchMainlineEvents({ groupBy, ...timeParams }));
}, [dispatch, activeTime, getTimeParams]);
// 切换时间范围
const handleTimeChange = useCallback((timeKey) => {
setActiveTime(timeKey);
// 数据会通过 useEffect 自动刷新
}, []);
// 点击事件
const handleEventPress = useCallback((event) => {
navigation.navigate('EventDetail', { eventId: event.id, title: event.title });
}, [navigation]);
// 解析主线数据
const mainlines = useMemo(() => {
// mainlineData 可能是 { mainlines: [...], total_events, ... } 或直接是数组
if (!mainlineData) return [];
if (Array.isArray(mainlineData)) return mainlineData;
return mainlineData.mainlines || [];
}, [mainlineData]);
// 统计信息
const stats = useMemo(() => {
if (!mainlineData || Array.isArray(mainlineData)) {
return { totalEvents: 0, mainlineCount: mainlines.length, ungroupedCount: 0 };
}
return {
totalEvents: mainlineData.total_events || 0,
mainlineCount: mainlineData.mainline_count || mainlines.length,
ungroupedCount: mainlineData.ungrouped_count || 0,
};
}, [mainlineData, mainlines]);
// 排序后的主线列表
const sortedMainlines = useMemo(() => {
if (!mainlines.length) return [];
const sorted = [...mainlines];
switch (sortBy) {
case 'change_desc':
return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999));
case 'change_asc':
return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999));
case 'event_count':
default:
return sorted.sort((a, b) => (b.event_count || 0) - (a.event_count || 0));
}
}, [mainlines, sortBy]);
// 渲染分组选择器
const renderGroupSelector = () => (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
>
<HStack space={2} alignItems="center">
{/* 时间范围选项 */}
{TIME_OPTIONS.map((option) => {
const isActive = activeTime === option.key;
return (
<Pressable key={option.key} onPress={() => handleTimeChange(option.key)}>
{isActive ? (
<LinearGradient
colors={gradients.secondary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.groupBadge}
>
<Text fontSize="xs" fontWeight="bold" color="white">
{option.label}
</Text>
</LinearGradient>
) : (
<Box
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="full"
px={3}
py={1}
>
<Text fontSize="xs" color="gray.400">
{option.label}
</Text>
</Box>
)}
</Pressable>
);
})}
{/* 分隔线 */}
<Box w="1px" h={5} bg="rgba(255,255,255,0.1)" mx={1} />
{/* 分组级别 */}
<Text fontSize="xs" color="gray.500" mr={1}>
分组:
</Text>
{GROUP_OPTIONS.map((option) => {
const isActive = mainlineGroupBy === option.key;
return (
<Pressable key={option.key} onPress={() => handleGroupByChange(option.key)}>
<Box
bg={isActive ? 'rgba(124, 58, 237, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
borderWidth={1}
borderColor={isActive ? 'rgba(124, 58, 237, 0.3)' : 'rgba(255, 255, 255, 0.08)'}
rounded="full"
px={3}
py={1}
>
<Text fontSize="xs" color={isActive ? 'primary.400' : 'gray.400'}>
{option.label}
</Text>
</Box>
</Pressable>
);
})}
{/* 分隔线 */}
<Box w="1px" h={5} bg="rgba(255,255,255,0.1)" mx={1} />
{/* 排序选项 */}
<Text fontSize="xs" color="gray.500" mr={1}>
排序:
</Text>
{SORT_OPTIONS.map((option) => {
const isActive = sortBy === option.key;
return (
<Pressable key={option.key} onPress={() => setSortBy(option.key)}>
<Box
bg={isActive ? 'rgba(251, 146, 60, 0.2)' : 'rgba(255, 255, 255, 0.05)'}
borderWidth={1}
borderColor={isActive ? 'rgba(251, 146, 60, 0.3)' : 'rgba(255, 255, 255, 0.08)'}
rounded="full"
px={3}
py={1}
>
<Text fontSize="xs" color={isActive ? '#FB923C' : 'gray.400'}>
{option.label}
</Text>
</Box>
</Pressable>
);
})}
</HStack>
</ScrollView>
);
// 渲染头部统计
const renderHeader = () => (
<VStack>
{renderGroupSelector()}
{/* 统计信息 */}
<HStack px={4} pb={2} alignItems="center" justifyContent="space-between">
<HStack alignItems="center" space={3}>
<HStack alignItems="center">
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.smallBadge}
>
<Icon as={Ionicons} name="trending-up" size="xs" color="white" />
</LinearGradient>
<Text fontSize="sm" fontWeight="bold" color="white" ml={2}>
{stats.mainlineCount} 条主线
</Text>
</HStack>
<Text fontSize="xs" color="gray.500">
{stats.totalEvents} 个事件
</Text>
</HStack>
{stats.ungroupedCount > 0 && (
<Box bg="rgba(251, 146, 60, 0.2)" rounded="full" px={2} py={0.5}>
<Text fontSize="2xs" color="#FB923C">
{stats.ungroupedCount} 未归类
</Text>
</Box>
)}
</HStack>
</VStack>
);
// 渲染空状态
const renderEmpty = () => {
if (loading.mainline) {
return (
<Center flex={1} py={20}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
正在加载主线数据...
</Text>
</Center>
);
}
return (
<Center flex={1} py={20}>
<Box bg="rgba(255, 255, 255, 0.05)" rounded="full" p={6} mb={4}>
<Icon as={Ionicons} name="layers-outline" size="4xl" color="gray.600" />
</Box>
<Text fontSize="md" color="gray.400" mb={2}>
暂无主线数据
</Text>
<Text fontSize="sm" color="gray.600">
尝试调整分组级别或下拉刷新
</Text>
</Center>
);
};
// 渲染主线卡片
const renderItem = useCallback(
({ item }) => (
<MainlineCard
item={item}
onEventPress={handleEventPress}
/>
),
[handleEventPress]
);
const keyExtractor = useCallback(
(item) => `mainline-${item.group_id || item.lv2_id || item.lv1_id || item.group_name}`,
[]
);
return (
<FlatList
data={sortedMainlines}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={renderHeader}
ListEmptyComponent={renderEmpty}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#06B6D4"
colors={['#06B6D4']}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
);
};
const styles = StyleSheet.create({
smallBadge: {
width: 24,
height: 24,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
groupBadge: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 16,
},
listContent: {
paddingBottom: 20,
},
});
MainlineCard.displayName = 'MainlineCard';
EventItem.displayName = 'EventItem';
export default MainlineView;

View File

@@ -0,0 +1,253 @@
/**
* 相关概念组件 - HeroUI 风格
* 展示事件关联的概念板块
*/
import React, { memo } from 'react';
import { StyleSheet } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { gradients } from '../../theme';
// 匹配类型配置
const MATCH_TYPE_CONFIG = {
hybrid: { label: '混合匹配', color: 'primary.400', bgColor: 'rgba(124, 58, 237, 0.15)' },
keyword: { label: '关键词', color: 'warning.400', bgColor: 'rgba(245, 158, 11, 0.15)' },
semantic: { label: '语义匹配', color: 'secondary.400', bgColor: 'rgba(6, 182, 212, 0.15)' },
};
// 格式化涨跌幅
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 'gray.500';
return value >= 0 ? '#EF4444' : '#22C55E'; // 涨红跌绿
};
// 单个概念卡片
const ConceptCard = memo(({ concept, onPress }) => {
const matchConfig = MATCH_TYPE_CONFIG[concept.match_type] || MATCH_TYPE_CONFIG.hybrid;
const avgChange = concept.price_info?.avg_change_pct;
return (
<Pressable onPress={() => onPress?.(concept)} _pressed={{ opacity: 0.8 }} flex={1}>
<Box
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="2xl"
p={3.5}
minH={24}
>
{/* 概念名称 */}
<Text
fontSize="sm"
fontWeight="bold"
color="white"
numberOfLines={1}
mb={2}
>
{concept.name || concept.concept}
</Text>
{/* 涨跌幅 */}
<HStack alignItems="center" space={1} mb={2}>
<Icon
as={Ionicons}
name={avgChange >= 0 ? 'trending-up' : 'trending-down'}
size="xs"
color={getChangeColor(avgChange)}
/>
<Text
fontSize="md"
fontWeight="bold"
color={getChangeColor(avgChange)}
>
{formatChange(avgChange)}
</Text>
</HStack>
{/* 匹配类型标签 */}
{concept.match_type && (
<Box
bg={matchConfig.bgColor}
rounded="md"
px={2}
py={0.5}
alignSelf="flex-start"
>
<Text fontSize="2xs" color={matchConfig.color}>
{matchConfig.label}
</Text>
</Box>
)}
{/* 关联原因 */}
{concept.reason && (
<Text
fontSize="xs"
color="gray.500"
numberOfLines={2}
mt={2}
lineHeight="sm"
>
{concept.reason}
</Text>
)}
</Box>
</Pressable>
);
});
ConceptCard.displayName = 'ConceptCard';
// 相关概念列表组件
const RelatedConcepts = ({
concepts = [],
loading = false,
onConceptPress,
maxDisplay = 6,
showAll = false,
onShowAll,
}) => {
// 加载中状态
if (loading) {
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
<Center py={6}>
<Spinner size="sm" color="primary.500" />
<Text fontSize="xs" color="gray.500" mt={2}>
加载相关概念...
</Text>
</Center>
</Box>
</Box>
);
}
// 空状态
if (!concepts || concepts.length === 0) {
return null;
}
const displayConcepts = showAll ? concepts : concepts.slice(0, maxDisplay);
const hasMore = concepts.length > maxDisplay && !showAll;
// 将概念分成两列
const rows = [];
for (let i = 0; i < displayConcepts.length; i += 2) {
rows.push(displayConcepts.slice(i, i + 2));
}
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
{/* 标题栏 */}
<HStack alignItems="center" justifyContent="space-between" mb={4}>
<HStack alignItems="center">
<LinearGradient
colors={gradients.warning}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="bulb" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
相关概念
</Text>
</HStack>
<Box
bg="rgba(249, 115, 22, 0.2)"
borderWidth={1}
borderColor="rgba(249, 115, 22, 0.3)"
rounded="full"
px={2.5}
py={1}
>
<Text fontSize="xs" color="warning.400" fontWeight="semibold">
{concepts.length}
</Text>
</Box>
</HStack>
{/* 概念网格 */}
<VStack space={3}>
{rows.map((row, rowIndex) => (
<HStack key={rowIndex} space={3}>
{row.map((concept, index) => (
<ConceptCard
key={concept.id || concept.concept || index}
concept={concept}
onPress={onConceptPress}
/>
))}
{/* 如果是奇数个,添加占位 */}
{row.length === 1 && <Box flex={1} />}
</HStack>
))}
</VStack>
{/* 查看更多按钮 */}
{hasMore && (
<Pressable onPress={onShowAll} mt={4}>
<HStack
alignItems="center"
justifyContent="center"
py={2.5}
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="xl"
>
<Text fontSize="sm" color="warning.400" mr={1}>
查看全部 {concepts.length} 个概念
</Text>
<Icon as={Ionicons} name="chevron-down" size="xs" color="warning.400" />
</HStack>
</Pressable>
)}
</Box>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
});
export default memo(RelatedConcepts);

View File

@@ -0,0 +1,308 @@
/**
* 相关股票组件 - HeroUI 风格
* 展示事件关联的股票列表
*/
import React, { memo } from 'react';
import { StyleSheet } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { gradients } from '../../theme';
// 格式化涨跌幅
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 'gray.500';
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)'; // 涨红跌绿
};
// 获取关联描述文本
const getRelationDesc = (relationDesc) => {
if (!relationDesc) return null;
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组后端新格式
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 isLast = index === total - 1;
// 使用报价数据或股票数据
const stockName = quote?.name || stock.stock_name || stock.name || `股票${(stock.stock_code || '').split('.')[0]}`;
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}>
<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>
)}
</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}
lineHeight="sm"
>
{relationDesc}
</Text>
)}
</VStack>
{/* 右侧:涨跌幅和价格 */}
<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)}
>
{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>
);
});
StockItem.displayName = 'StockItem';
// 相关股票列表组件
const RelatedStocks = ({
stocks = [],
quotes = {},
loading = false,
loadingQuotes = false,
onStockPress,
maxDisplay = 10,
showAll = false,
onShowAll,
}) => {
// 加载中状态
if (loading) {
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
<Center py={6}>
<Spinner size="sm" color="primary.500" />
<Text fontSize="xs" color="gray.500" mt={2}>
加载相关股票...
</Text>
</Center>
</Box>
</Box>
);
}
// 空状态
if (!stocks || stocks.length === 0) {
return null;
}
const displayStocks = showAll ? stocks : stocks.slice(0, maxDisplay);
const hasMore = stocks.length > maxDisplay && !showAll;
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
{/* 标题栏 */}
<HStack alignItems="center" justifyContent="space-between" mb={4}>
<HStack alignItems="center">
<LinearGradient
colors={gradients.secondary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="stats-chart" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
相关股票
</Text>
</HStack>
<Box
bg="rgba(6, 182, 212, 0.2)"
borderWidth={1}
borderColor="rgba(6, 182, 212, 0.3)"
rounded="full"
px={2.5}
py={1}
>
<Text fontSize="xs" color="secondary.400" fontWeight="semibold">
{stocks.length}
</Text>
</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) => (
<StockItem
key={stock.id || stock.stock_code || index}
stock={stock}
quote={quotes[stock.stock_code]}
index={index}
total={displayStocks.length}
onPress={onStockPress}
/>
))}
</VStack>
{/* 报价加载中提示 */}
{loadingQuotes && (
<HStack justifyContent="center" alignItems="center" py={2}>
<Spinner size="sm" color="secondary.400" />
<Text fontSize="xs" color="gray.500" ml={2}>
加载报价中...
</Text>
</HStack>
)}
{/* 查看更多按钮 */}
{hasMore && (
<Pressable onPress={onShowAll} mt={3}>
<HStack
alignItems="center"
justifyContent="center"
py={2.5}
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="xl"
>
<Text fontSize="sm" color="secondary.400" mr={1}>
查看全部 {stocks.length} 只股票
</Text>
<Icon as={Ionicons} name="chevron-down" size="xs" color="secondary.400" />
</HStack>
</Pressable>
)}
</Box>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
});
export default memo(RelatedStocks);

View File

@@ -0,0 +1,293 @@
/**
* 桑基图/流向分析组件 - HeroUI 风格
* 简化版列表展示事件的影响流向
*/
import React, { useState, useEffect, memo } from 'react';
import { StyleSheet } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { gradients } from '../../theme';
import eventService from '../../services/eventService';
// 节点类型配置
const NODE_TYPE_CONFIG = {
event: { color: '#F43F5E', bgColor: 'rgba(244, 63, 94, 0.15)' },
policy: { color: '#8B5CF6', bgColor: 'rgba(139, 92, 246, 0.15)' },
technology: { color: '#06B6D4', bgColor: 'rgba(6, 182, 212, 0.15)' },
industry: { color: '#F59E0B', bgColor: 'rgba(245, 158, 11, 0.15)' },
company: { color: '#10B981', bgColor: 'rgba(16, 185, 129, 0.15)' },
product: { color: '#EC4899', bgColor: 'rgba(236, 72, 153, 0.15)' },
};
// 单个流向项
const FlowItem = memo(({ flow, maxValue }) => {
const sourceConfig = NODE_TYPE_CONFIG[flow.source_type] || NODE_TYPE_CONFIG.event;
const targetConfig = NODE_TYPE_CONFIG[flow.target_type] || NODE_TYPE_CONFIG.company;
const flowPercent = maxValue > 0 ? (flow.flow_value / maxValue) * 100 : 0;
return (
<Box
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={4}
mb={3}
>
{/* 流向路径 */}
<HStack alignItems="center" mb={3}>
{/* 源节点 */}
<Box
bg={sourceConfig.bgColor}
borderWidth={1}
borderColor={`${sourceConfig.color}40`}
rounded="lg"
px={2.5}
py={1.5}
flex={1}
>
<Text fontSize="xs" color={sourceConfig.color} fontWeight="semibold" numberOfLines={1}>
{flow.source_node}
</Text>
</Box>
{/* 箭头 */}
<VStack alignItems="center" mx={2}>
<Icon as={Ionicons} name="arrow-forward" size="sm" color="gray.500" />
<Text fontSize="2xs" color="gray.600" mt={-0.5}>
{flow.flow_ratio ? `${(flow.flow_ratio * 100).toFixed(0)}%` : ''}
</Text>
</VStack>
{/* 目标节点 */}
<Box
bg={targetConfig.bgColor}
borderWidth={1}
borderColor={`${targetConfig.color}40`}
rounded="lg"
px={2.5}
py={1.5}
flex={1}
>
<Text fontSize="xs" color={targetConfig.color} fontWeight="semibold" numberOfLines={1}>
{flow.target_node}
</Text>
</Box>
</HStack>
{/* 流量条 */}
<Box mb={2}>
<HStack justifyContent="space-between" mb={1}>
<Text fontSize="2xs" color="gray.500">影响强度</Text>
<Text fontSize="2xs" color="gray.400">{flow.flow_value?.toFixed(1) || 0}</Text>
</HStack>
<Box h={1.5} bg="rgba(255,255,255,0.1)" rounded="full">
<LinearGradient
colors={[sourceConfig.color, targetConfig.color]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.flowBar, { width: `${Math.min(flowPercent, 100)}%` }]}
/>
</Box>
</Box>
{/* 传导路径说明 */}
{flow.transmission_path && (
<HStack alignItems="center" mt={1}>
<Icon as={Ionicons} name="git-branch" size="2xs" color="gray.500" mr={1} />
<Text fontSize="2xs" color="gray.500" flex={1} numberOfLines={1}>
{flow.transmission_path}
</Text>
</HStack>
)}
{/* 影响描述 */}
{flow.impact_description && (
<Text fontSize="xs" color="gray.400" mt={2} numberOfLines={2}>
{flow.impact_description}
</Text>
)}
{/* 证据强度 */}
{flow.evidence_strength !== undefined && (
<HStack alignItems="center" mt={2}>
<Text fontSize="2xs" color="gray.600" mr={2}>证据强度:</Text>
{[1, 2, 3, 4, 5].map(level => (
<Icon
key={level}
as={Ionicons}
name={level <= (flow.evidence_strength / 20) ? 'star' : 'star-outline'}
size="2xs"
color={level <= (flow.evidence_strength / 20) ? '#FBBF24' : 'gray.600'}
/>
))}
</HStack>
)}
</Box>
);
});
FlowItem.displayName = 'FlowItem';
// 桑基图组件
const SankeyFlow = ({ eventId, maxDisplay = 5 }) => {
const [flows, setFlows] = useState([]);
const [loading, setLoading] = useState(true);
const [showAll, setShowAll] = useState(false);
const [error, setError] = useState(null);
// 加载桑基图数据
useEffect(() => {
const loadData = async () => {
setLoading(true);
setError(null);
try {
const response = await eventService.getSankeyData(eventId);
if (response.success && response.data?.links) {
// 转换数据格式
const flowData = response.data.links.map((link, index) => ({
id: index,
source_node: link.source,
target_node: link.target,
source_type: response.data.nodes?.find(n => n.name === link.source)?.category || 'event',
target_type: response.data.nodes?.find(n => n.name === link.target)?.category || 'company',
flow_value: link.value || 0,
flow_ratio: link.ratio,
transmission_path: link.path,
impact_description: link.description,
evidence_strength: link.evidence,
}));
setFlows(flowData);
}
} catch (error) {
console.error('加载桑基图失败:', error);
setError('加载失败');
} finally {
setLoading(false);
}
};
loadData();
}, [eventId]);
// 空状态不显示
if (!loading && flows.length === 0) {
return null;
}
const displayFlows = showAll ? flows : flows.slice(0, maxDisplay);
const hasMore = flows.length > maxDisplay && !showAll;
const maxValue = Math.max(...flows.map(f => f.flow_value || 0), 1);
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
{/* 标题栏 */}
<HStack alignItems="center" justifyContent="space-between" mb={4}>
<HStack alignItems="center">
<LinearGradient
colors={gradients.success}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="analytics" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
影响流向
</Text>
</HStack>
{flows.length > 0 && (
<Box
bg="rgba(16, 185, 129, 0.2)"
borderWidth={1}
borderColor="rgba(16, 185, 129, 0.3)"
rounded="full"
px={2.5}
py={1}
>
<Text fontSize="xs" color="success.400" fontWeight="semibold">
{flows.length}条路径
</Text>
</Box>
)}
</HStack>
{/* 加载状态 */}
{loading ? (
<Center py={8}>
<Spinner size="sm" color="primary.500" />
<Text fontSize="xs" color="gray.500" mt={2}>
分析影响流向...
</Text>
</Center>
) : error ? (
<Center py={6}>
<Icon as={Ionicons} name="warning" size="xl" color="gray.600" mb={2} />
<Text fontSize="sm" color="gray.500">
{error}
</Text>
</Center>
) : (
<VStack>
{displayFlows.map((flow) => (
<FlowItem key={flow.id} flow={flow} maxValue={maxValue} />
))}
</VStack>
)}
{/* 查看更多 */}
{hasMore && (
<Pressable onPress={() => setShowAll(true)}>
<HStack
alignItems="center"
justifyContent="center"
py={2.5}
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="xl"
>
<Text fontSize="sm" color="success.400" mr={1}>
查看全部 {flows.length} 条路径
</Text>
<Icon as={Ionicons} name="chevron-down" size="xs" color="success.400" />
</HStack>
</Pressable>
)}
</Box>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
flowBar: {
height: 6,
borderRadius: 3,
},
});
export default memo(SankeyFlow);

View File

@@ -0,0 +1,294 @@
/**
* 股票详情模态框组件
* 展示股票的详细信息
*/
import React, { memo } from 'react';
import { StyleSheet, Modal, Dimensions } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { gradients } from '../../theme';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 格式化涨跌幅
const formatChange = (value) => {
if (value === null || value === undefined) return '--';
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
};
// 格式化价格
const formatPrice = (value) => {
if (value === null || value === undefined) return '--';
return `¥${value.toFixed(2)}`;
};
// 获取涨跌幅颜色(中国标准:涨红跌绿)
const getChangeColor = (value) => {
if (value === null || value === undefined) return '#64748B';
return value >= 0 ? '#EF4444' : '#22C55E';
};
// 获取涨跌幅背景色
const getChangeBgColor = (value) => {
if (value === null || value === undefined) return 'rgba(100, 116, 139, 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;
}
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 StockDetailModal = ({ visible, stock, quote, onClose, onViewMore }) => {
if (!stock) return null;
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);
const sector = stock.sector;
const correlation = stock.correlation;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable
style={styles.overlay}
onPress={onClose}
>
<BlurView intensity={20} style={StyleSheet.absoluteFill} tint="dark" />
<Pressable onPress={(e) => e.stopPropagation()}>
<Box
bg="rgba(30, 41, 59, 0.95)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
mx={4}
width={SCREEN_WIDTH - 32}
maxWidth={400}
>
{/* 关闭按钮 */}
<Pressable
onPress={onClose}
position="absolute"
top={4}
right={4}
zIndex={1}
>
<Box
bg="rgba(255, 255, 255, 0.1)"
rounded="full"
p={2}
>
<Icon as={Ionicons} name="close" size="sm" color="gray.400" />
</Box>
</Pressable>
{/* 股票头部信息 */}
<VStack space={1} mb={4}>
<HStack alignItems="center" space={2}>
<Text fontSize="xl" fontWeight="bold" color="white">
{stockName}
</Text>
{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">
{sector}
</Text>
</Box>
)}
</HStack>
<Text fontSize="sm" color="gray.500">
{stockCode}
</Text>
</VStack>
{/* 价格和涨跌幅 */}
<Box
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={4}
mb={4}
>
<HStack justifyContent="space-between" alignItems="center">
<VStack>
<Text fontSize="xs" color="gray.500" mb={1}>
当前价格
</Text>
<Text fontSize="2xl" fontWeight="bold" color="white">
{formatPrice(price)}
</Text>
</VStack>
<VStack alignItems="flex-end">
<Text fontSize="xs" color="gray.500" mb={1}>
涨跌幅
</Text>
<Box
bg={getChangeBgColor(change)}
rounded="lg"
px={3}
py={1.5}
>
<HStack alignItems="center" space={1}>
<Icon
as={Ionicons}
name={change >= 0 ? 'trending-up' : 'trending-down'}
size="sm"
color={getChangeColor(change)}
/>
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(change)}
>
{formatChange(change)}
</Text>
</HStack>
</Box>
</VStack>
</HStack>
</Box>
{/* 相关性分数 */}
{correlation !== undefined && correlation !== null && (
<Box
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={4}
mb={4}
>
<HStack alignItems="center" justifyContent="space-between">
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="git-network" size="sm" color="secondary.400" />
<Text fontSize="sm" color="gray.400">
与事件相关性
</Text>
</HStack>
<HStack alignItems="center" space={2}>
<Box flex={1} maxW={100} h={2} bg="rgba(255,255,255,0.1)" rounded="full">
<Box
w={`${correlation * 100}%`}
h="100%"
bg="secondary.400"
rounded="full"
/>
</Box>
<Text fontSize="sm" fontWeight="bold" color="secondary.400">
{(correlation * 100).toFixed(0)}%
</Text>
</HStack>
</HStack>
</Box>
)}
{/* 关联原因 */}
{relationDesc && (
<Box
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={4}
mb={4}
>
<HStack alignItems="center" space={2} mb={2}>
<Icon as={Ionicons} name="information-circle" size="sm" color="warning.400" />
<Text fontSize="sm" fontWeight="semibold" color="gray.300">
关联原因
</Text>
</HStack>
<Text fontSize="sm" color="gray.400" lineHeight="lg">
{relationDesc}
</Text>
</Box>
)}
{/* 查看更多按钮 */}
<Pressable onPress={() => onViewMore?.(stock)}>
<LinearGradient
colors={gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.viewMoreButton}
>
<HStack alignItems="center" space={2}>
<Icon as={Ionicons} name="analytics" size="sm" color="white" />
<Text fontSize="sm" fontWeight="bold" color="white">
查看股票详情
</Text>
</HStack>
</LinearGradient>
</Pressable>
{/* 提示文字 */}
<Center mt={3}>
<Text fontSize="2xs" color="gray.600">
点击查看更多股票分析信息
</Text>
</Center>
</Box>
</Pressable>
</Pressable>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
viewMoreButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 16,
},
});
export default memo(StockDetailModal);

View File

@@ -0,0 +1,541 @@
/**
* 传导链分析组件 - 桑基图风格
* 展示事件的影响传导路径
*/
import React, { useState, useEffect, memo, useMemo } from 'react';
import { StyleSheet, Dimensions } from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
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)' },
};
// 传导方向配置(涨红跌绿)
const DIRECTION_CONFIG = {
positive: { label: '利好', color: '#EF4444' },
negative: { label: '利空', color: '#22C55E' },
neutral: { label: '中性', color: '#64748B' },
mixed: { label: '复杂', color: '#F59E0B' },
};
// 计算桑基图布局
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 config = NODE_TYPE_CONFIG[node.extra?.node_type] || NODE_TYPE_CONFIG.other;
const isMainEvent = node.extra?.is_main_event;
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}
>
{node.name?.length > 8 ? node.name.slice(0, 8) + '...' : node.name}
</Text>
</VStack>
</LinearGradient>
</Pressable>
);
});
SankeyNode.displayName = 'SankeyNode';
// 传导链分析组件
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(() => {
const loadData = async () => {
setLoading(true);
setError(null);
try {
const response = await eventService.getTransmissionChain(eventId);
if (response.success) {
setData({
nodes: response.data.nodes || [],
edges: response.data.edges || [],
});
} else {
if (response.message?.includes('订阅') || response.message?.includes('Max') || response.status === 403) {
setError('需要 Max 订阅才能查看传导链分析');
}
}
} catch (error) {
console.error('加载传导链失败:', error);
setError('加载失败');
} finally {
setLoading(false);
}
};
loadData();
}, [eventId]);
// 计算布局
const layout = useMemo(() => {
return calculateSankeyLayout(data.nodes, data.edges);
}, [data.nodes, data.edges]);
// 点击节点
const handleNodePress = (node) => {
setSelectedNode(selectedNode?.id === node.id ? null : node);
};
// 空状态或错误
if (!loading && (data.nodes.length === 0 || error)) {
if (error) {
return (
<Box mx={4} rounded="3xl" overflow="hidden">
<Box
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
<HStack alignItems="center" mb={4}>
<LinearGradient
colors={gradients.blue}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="git-network" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
传导链分析
</Text>
</HStack>
<Center py={6}>
<Icon as={Ionicons} name="lock-closed" size="2xl" color="gray.600" mb={2} />
<Text fontSize="sm" color="gray.500" textAlign="center">
{error}
</Text>
</Center>
</Box>
</Box>
);
}
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
bg="rgba(30, 41, 59, 0.8)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={4}
>
{/* 标题栏 */}
<HStack alignItems="center" justifyContent="space-between" mb={3}>
<HStack alignItems="center">
<LinearGradient
colors={gradients.blue}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="git-network" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
传导链分析
</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>
)}
</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}>
<Spinner size="sm" color="primary.500" />
<Text fontSize="xs" color="gray.500" mt={2}>
分析传导链...
</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>
)}
</Box>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
sankeyNode: {
borderRadius: 8,
padding: 4,
},
});
export default memo(TransmissionChain);

View File

@@ -0,0 +1,7 @@
/**
* Events 模块导出
*/
export { default as EventList } from './EventList';
export { default as EventDetail } from './EventDetail';
export { default as EventCard } from './EventCard';

View File

@@ -0,0 +1,657 @@
/**
* 事件日历页面 - 黑金主题完整版
* 展示涨停历史、未来事件、跨天热门概念
* 仿照 Web 端 FullCalendarPro 实现
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
ScrollView,
StyleSheet,
StatusBar,
Dimensions,
TouchableOpacity,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import ztService from '../../services/ztService';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CELL_WIDTH = (SCREEN_WIDTH - 32) / 7;
const CELL_HEIGHT = 90; // 增加高度以容纳更多内容
// 概念颜色调色板
const CONCEPT_COLORS = [
{ bg: ['#FFD700', '#FFA500'], border: '#FFD700', text: '#1a1a2e' }, // 金色
{ bg: ['#00CED1', '#20B2AA'], border: '#00CED1', text: '#1a1a2e' }, // 青色
{ bg: ['#FF6B6B', '#EE5A5A'], border: '#FF6B6B', text: '#fff' }, // 红色
{ bg: ['#A855F7', '#9333EA'], border: '#A855F7', text: '#fff' }, // 紫色
{ bg: ['#3B82F6', '#2563EB'], border: '#3B82F6', text: '#fff' }, // 蓝色
{ bg: ['#10B981', '#059669'], border: '#10B981', text: '#1a1a2e' }, // 绿色
{ bg: ['#F59E0B', '#D97706'], border: '#F59E0B', text: '#1a1a2e' }, // 橙色
{ bg: ['#EC4899', '#DB2777'], border: '#EC4899', text: '#fff' }, // 粉色
];
const conceptColorMap = {};
let colorIndex = 0;
const getConceptColor = (concept) => {
if (!conceptColorMap[concept]) {
conceptColorMap[concept] = CONCEPT_COLORS[colorIndex % CONCEPT_COLORS.length];
colorIndex++;
}
return conceptColorMap[concept];
};
// 获取月份的天数信息
const getMonthDays = (year, month) => {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDay = firstDay.getDay();
return { daysInMonth, startingDay };
};
// 格式化日期为 YYYYMMDD
const formatDateStr = (year, month, day) => {
const m = String(month + 1).padStart(2, '0');
const d = String(day).padStart(2, '0');
return `${year}${m}${d}`;
};
// 检查是否连续日期(跳过周末)
const isConsecutiveDate = (date1, date2) => {
const d1 = new Date(
parseInt(date1.slice(0, 4)),
parseInt(date1.slice(4, 6)) - 1,
parseInt(date1.slice(6, 8))
);
const d2 = new Date(
parseInt(date2.slice(0, 4)),
parseInt(date2.slice(4, 6)) - 1,
parseInt(date2.slice(6, 8))
);
const diff = (d2 - d1) / (1000 * 60 * 60 * 24);
if (diff === 1) return true;
if (diff === 2 && d1.getDay() === 5) return true; // 周五到周日
if (diff === 3 && d1.getDay() === 5) return true; // 周五到周一
return false;
};
// 合并连续相同概念
const mergeConsecutiveConcepts = (calendarData, year, month) => {
const sorted = [...calendarData]
.filter(d => d.topSector)
.sort((a, b) => a.date.localeCompare(b.date));
const events = [];
let currentEvent = null;
sorted.forEach((item, index) => {
const prevItem = sorted[index - 1];
const isConsecutive = prevItem &&
item.topSector === prevItem.topSector &&
isConsecutiveDate(prevItem.date, item.date);
if (isConsecutive && currentEvent) {
currentEvent.endDate = item.date;
currentEvent.dates.push(item.date);
} else {
if (currentEvent && currentEvent.dates.length > 1) {
events.push(currentEvent);
}
currentEvent = {
concept: item.topSector,
startDate: item.date,
endDate: item.date,
dates: [item.date],
};
}
});
if (currentEvent && currentEvent.dates.length > 1) {
events.push(currentEvent);
}
return events;
};
// 火焰图标组件
const FlameIcon = ({ color, size = 14 }) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"
stroke={color}
strokeWidth={2}
fill="none"
/>
</Svg>
);
const EventCalendar = ({ navigation }) => {
const insets = useSafeAreaInsets();
const today = new Date();
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [calendarData, setCalendarData] = useState([]);
const [loading, setLoading] = useState(false);
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const { daysInMonth, startingDay } = useMemo(() =>
getMonthDays(currentYear, currentMonth),
[currentYear, currentMonth]
);
// 加载日历数据
const loadCalendarData = useCallback(async () => {
setLoading(true);
try {
const result = await ztService.getCalendarDataFast(currentYear, currentMonth + 1);
if (result.success) {
setCalendarData(result.data);
}
} catch (error) {
console.error('加载日历数据失败:', error);
} finally {
setLoading(false);
}
}, [currentYear, currentMonth]);
useEffect(() => {
loadCalendarData();
}, [loadCalendarData]);
// 创建日期数据映射
const dataMap = useMemo(() => {
const map = {};
calendarData.forEach(d => {
map[d.date] = d;
});
return map;
}, [calendarData]);
// 获取跨天概念事件
const conceptEvents = useMemo(() =>
mergeConsecutiveConcepts(calendarData, currentYear, currentMonth),
[calendarData, currentYear, currentMonth]
);
// 切换月份
const handlePrevMonth = useCallback(() => {
if (currentMonth === 0) {
setCurrentYear(y => y - 1);
setCurrentMonth(11);
} else {
setCurrentMonth(m => m - 1);
}
}, [currentMonth]);
const handleNextMonth = useCallback(() => {
if (currentMonth === 11) {
setCurrentYear(y => y + 1);
setCurrentMonth(0);
} else {
setCurrentMonth(m => m + 1);
}
}, [currentMonth]);
const handleToday = useCallback(() => {
setCurrentYear(today.getFullYear());
setCurrentMonth(today.getMonth());
}, []);
// 点击日期
const handleDatePress = useCallback((day) => {
const dateStr = formatDateStr(currentYear, currentMonth, day);
navigation.navigate('MarketHot', { date: dateStr });
}, [currentYear, currentMonth, navigation]);
// 计算概念条位置
const getConceptBarPosition = (event, rowIndex) => {
const startDay = parseInt(event.startDate.slice(6, 8));
const endDay = parseInt(event.endDate.slice(6, 8));
// 计算在网格中的位置
const startCol = (startingDay + startDay - 1) % 7;
const startRow = Math.floor((startingDay + startDay - 1) / 7);
const endCol = (startingDay + endDay - 1) % 7;
const endRow = Math.floor((startingDay + endDay - 1) / 7);
// 只处理同一行的情况(简化版)
if (startRow !== endRow) return null;
if (startRow !== rowIndex) return null;
return {
left: startCol * CELL_WIDTH + 4,
width: (endCol - startCol + 1) * CELL_WIDTH - 8,
row: startRow,
};
};
// 渲染日历格子
const renderCalendarCells = () => {
const rows = [];
let cells = [];
// 填充月初空白
for (let i = 0; i < startingDay; i++) {
cells.push(
<Box key={`empty-${i}`} w={CELL_WIDTH} h={CELL_HEIGHT} />
);
}
// 填充日期
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = formatDateStr(currentYear, currentMonth, day);
const data = dataMap[dateStr];
const isToday = day === today.getDate() &&
currentMonth === today.getMonth() &&
currentYear === today.getFullYear();
const dayOfWeek = (startingDay + day - 1) % 7;
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
cells.push(
<TouchableOpacity
key={day}
onPress={() => handleDatePress(day)}
activeOpacity={0.7}
>
<Box
w={CELL_WIDTH}
h={CELL_HEIGHT}
borderWidth={0.5}
borderColor="rgba(212, 175, 55, 0.15)"
bg={isToday ? 'rgba(212, 175, 55, 0.15)' : 'rgba(15, 15, 22, 0.4)'}
p={1}
>
{/* 第一行:日期 + 涨跌幅 */}
<HStack justifyContent="space-between" alignItems="flex-start">
<Text
fontSize="md"
fontWeight={isToday ? 'bold' : '600'}
color={isToday ? '#FFD700' : isWeekend ? '#FB923C' : 'white'}
>
{day}
</Text>
{data?.indexChange !== null && data?.indexChange !== undefined && (
<Text
fontSize="xs"
fontWeight="bold"
color={data.indexChange >= 0 ? '#EF4444' : '#22C55E'}
>
{data.indexChange >= 0 ? '+' : ''}{data.indexChange?.toFixed(2)}%
</Text>
)}
</HStack>
{/* 涨停数据 */}
{data?.ztCount > 0 && (
<HStack alignItems="center" justifyContent="center" mt={1}>
<FlameIcon
color={data.ztCount >= 60 ? '#EF4444' : '#F59E0B'}
size={14}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={data.ztCount >= 60 ? '#EF4444' : '#F59E0B'}
ml={1}
>
{data.ztCount}
</Text>
</HStack>
)}
{/* 事件数量 */}
{data?.eventCount > 0 && (
<HStack alignItems="center" justifyContent="center" mt={1}>
<Box
bg="rgba(34, 197, 94, 0.9)"
rounded="full"
w={4}
h={4}
alignItems="center"
justifyContent="center"
mr={1}
>
<Text fontSize="2xs" fontWeight="bold" color="white">
{data.eventCount}
</Text>
</Box>
<Text fontSize="2xs" color="#22C55E" fontWeight="600">
事件
</Text>
</HStack>
)}
</Box>
</TouchableOpacity>
);
// 每7个一行
if (cells.length === 7) {
rows.push(
<HStack key={`row-${rows.length}`}>
{cells}
</HStack>
);
cells = [];
}
}
// 最后一行
if (cells.length > 0) {
while (cells.length < 7) {
cells.push(
<Box key={`empty-end-${cells.length}`} w={CELL_WIDTH} h={CELL_HEIGHT} />
);
}
rows.push(
<HStack key={`row-${rows.length}`}>
{cells}
</HStack>
);
}
return rows;
};
// 渲染概念横条
const renderConceptBars = () => {
const totalRows = Math.ceil((startingDay + daysInMonth) / 7);
const bars = [];
conceptEvents.forEach((event, eventIndex) => {
for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
const position = getConceptBarPosition(event, rowIndex);
if (!position) continue;
const color = getConceptColor(event.concept);
const daysCount = event.dates.length;
bars.push(
<Box
key={`${event.concept}-${event.startDate}-${rowIndex}`}
position="absolute"
left={position.left}
top={rowIndex * CELL_HEIGHT + CELL_HEIGHT - 28}
w={position.width}
zIndex={10}
>
<LinearGradient
colors={color.bg}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.conceptBar}
>
<Text
fontSize="xs"
fontWeight="bold"
color={color.text}
numberOfLines={1}
>
{event.concept}
{daysCount > 1 && (
<Text fontSize="2xs" opacity={0.8}>
{' '}({daysCount})
</Text>
)}
</Text>
</LinearGradient>
</Box>
);
}
});
return bars;
};
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: insets.bottom + 20,
}}
>
{/* 标题栏 */}
<Box px={4} pt={4} pb={2}>
<HStack alignItems="center" justifyContent="space-between">
<HStack alignItems="center">
<Pressable
onPress={() => navigation.goBack()}
p={2}
mr={2}
rounded="full"
bg="rgba(255,255,255,0.05)"
>
<Icon as={Ionicons} name="arrow-back" size="md" color="white" />
</Pressable>
<VStack>
<Text fontSize="xl" fontWeight="bold" color="white">
涨停与未来日历
</Text>
<Text fontSize="xs" color="gray.500">
涨停数据 · 事件追踪 · 概念连续
</Text>
</VStack>
</HStack>
<Pressable
onPress={() => navigation.openDrawer?.()}
p={2}
rounded="full"
bg="rgba(255,255,255,0.1)"
>
<Icon as={Ionicons} name="menu" size="md" color="white" />
</Pressable>
</HStack>
</Box>
{/* 月份导航 - 黑金主题 */}
<Box mx={4} my={4} rounded="2xl" overflow="hidden">
<LinearGradient
colors={['rgba(212, 175, 55, 0.1)', 'rgba(184, 134, 11, 0.05)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.monthNav}
>
<Box
borderWidth={1}
borderColor="rgba(212, 175, 55, 0.2)"
rounded="2xl"
p={3}
>
<HStack alignItems="center" justifyContent="space-between">
{/* 左箭头 */}
<Pressable
onPress={handlePrevMonth}
bg="rgba(212, 175, 55, 0.2)"
borderWidth={1}
borderColor="rgba(212, 175, 55, 0.4)"
rounded="lg"
p={2}
>
<Icon as={Ionicons} name="chevron-back" size="sm" color="#FFD700" />
</Pressable>
{/* 右箭头 */}
<Pressable
onPress={handleNextMonth}
bg="rgba(212, 175, 55, 0.2)"
borderWidth={1}
borderColor="rgba(212, 175, 55, 0.4)"
rounded="lg"
p={2}
>
<Icon as={Ionicons} name="chevron-forward" size="sm" color="#FFD700" />
</Pressable>
{/* 今天按钮 */}
<Pressable
onPress={handleToday}
bg="rgba(212, 175, 55, 0.2)"
borderWidth={1}
borderColor="rgba(212, 175, 55, 0.4)"
rounded="lg"
px={3}
py={2}
>
<Text fontSize="sm" fontWeight="600" color="#FFD700">
今天
</Text>
</Pressable>
{/* 月份标题 */}
<Text
fontSize="xl"
fontWeight="bold"
color="#FFD700"
flex={1}
textAlign="center"
>
{currentYear}{monthNames[currentMonth]}
</Text>
</HStack>
</Box>
</LinearGradient>
</Box>
{/* 星期标题 - 黑金主题 */}
<Box mx={4} bg="rgba(212, 175, 55, 0.1)" rounded="lg" py={2}>
<HStack>
{weekDays.map((day, index) => (
<Box key={day} w={CELL_WIDTH} alignItems="center">
<Text
fontSize="sm"
fontWeight="600"
color="#FFD700"
>
{day}
</Text>
</Box>
))}
</HStack>
</Box>
{/* 日历网格 */}
<Box mx={4} mt={2} position="relative">
{loading ? (
<Center py={20}>
<Spinner size="lg" color="#FFD700" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载日历数据...
</Text>
</Center>
) : (
<Box position="relative">
{/* 日历格子 */}
<VStack>
{renderCalendarCells()}
</VStack>
{/* 概念横条(覆盖层) */}
{renderConceptBars()}
</Box>
)}
</Box>
{/* 图例说明 */}
<Box mx={4} mt={4}>
<Box
bg="rgba(15, 15, 22, 0.6)"
borderWidth={1}
borderColor="rgba(212, 175, 55, 0.2)"
rounded="xl"
p={3}
>
<HStack justifyContent="center" space={3} flexWrap="wrap">
<HStack alignItems="center">
<LinearGradient
colors={['#FFD700', '#FFA500']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.legendBar}
/>
<Text fontSize="2xs" color="gray.400" ml={1}>连续热门概念</Text>
</HStack>
<HStack alignItems="center">
<FlameIcon color="#EF4444" size={12} />
<Text fontSize="2xs" color="gray.400" ml={1}>涨停60</Text>
</HStack>
<HStack alignItems="center">
<FlameIcon color="#F59E0B" size={12} />
<Text fontSize="2xs" color="gray.400" ml={1}>涨停&lt;60</Text>
</HStack>
<HStack alignItems="center">
<Box
w={3.5}
h={3.5}
rounded="full"
bg="#22C55E"
alignItems="center"
justifyContent="center"
>
<Text fontSize="6px" fontWeight="bold" color="white">N</Text>
</Box>
<Text fontSize="2xs" color="gray.400" ml={1}>未来事件</Text>
</HStack>
<HStack alignItems="center">
<Text fontSize="2xs" fontWeight="600" color="#EF4444">+0.5%</Text>
<Text fontSize="2xs" color="gray.500" mx={0.5}>/</Text>
<Text fontSize="2xs" fontWeight="600" color="#22C55E">-0.5%</Text>
<Text fontSize="2xs" color="gray.400" ml={1}>上证涨跌</Text>
</HStack>
</HStack>
</Box>
</Box>
{/* 操作提示 */}
<Box mx={4} mt={3}>
<HStack alignItems="center" justifyContent="center">
<Icon as={Ionicons} name="information-circle" size="xs" color="gray.600" mr={1} />
<Text fontSize="2xs" color="gray.600">
点击日期查看当天涨停详情
</Text>
</HStack>
</Box>
</ScrollView>
</Box>
);
};
const styles = StyleSheet.create({
monthNav: {
borderRadius: 16,
},
conceptBar: {
height: 22,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
},
legendBar: {
width: 20,
height: 8,
borderRadius: 4,
},
});
export default EventCalendar;

View File

@@ -0,0 +1,676 @@
/**
* 市场热点页面 - HeroUI 风格
* 展示涨停板块、个股、热门概念
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
StatusBar,
ScrollView,
Dimensions,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ztService from '../../services/ztService';
import { gradients } from '../../theme';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 日期格式化
const formatDisplayDate = (date) => {
const d = new Date(date);
const month = d.getMonth() + 1;
const day = d.getDate();
const weekDay = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()];
return `${month}${day}${weekDay}`;
};
const formatApiDate = (date) => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
};
// 热度颜色
const getHeatColor = (count) => {
if (count >= 10) return { bg: 'rgba(147, 51, 234, 0.25)', border: 'rgba(147, 51, 234, 0.5)', text: '#A78BFA' };
if (count >= 5) return { bg: 'rgba(239, 68, 68, 0.2)', border: 'rgba(239, 68, 68, 0.4)', text: '#F87171' };
if (count >= 3) return { bg: 'rgba(251, 146, 60, 0.2)', border: 'rgba(251, 146, 60, 0.4)', text: '#FB923C' };
return { bg: 'rgba(59, 130, 246, 0.15)', border: 'rgba(59, 130, 246, 0.3)', text: '#60A5FA' };
};
// 连板颜色
const getContinuousColor = (days) => {
if (days >= 5) return '#A78BFA'; // 紫色
if (days >= 3) return '#F43F5E'; // 红色
if (days >= 2) return '#FB923C'; // 橙色
return '#64748B'; // 灰色
};
// 解析连板天数
const parseContinuousDays = (str) => {
if (!str || str === '首板') return 1;
const match = str.match(/(\d+)/);
return match ? parseInt(match[1]) : 1;
};
const MarketHot = ({ navigation }) => {
const insets = useSafeAreaInsets();
const [currentDate, setCurrentDate] = useState(new Date());
const [ztData, setZtData] = useState(null);
const [stats, setStats] = useState(null);
const [hotSectors, setHotSectors] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 加载数据
const loadData = useCallback(async (date, isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
const dateStr = formatApiDate(date);
const result = await ztService.getDailyZt(dateStr);
if (result.success) {
setZtData(result.data);
setStats(ztService.calculateStats(result.data));
setHotSectors(ztService.getHotSectors(result.data, 10));
} else {
// 尝试获取最新数据
const latestResult = await ztService.getLatestZt();
if (latestResult.success) {
setZtData(latestResult.data);
setStats(ztService.calculateStats(latestResult.data));
setHotSectors(ztService.getHotSectors(latestResult.data, 10));
}
}
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// 初始加载
useEffect(() => {
loadData(currentDate);
}, [currentDate, loadData]);
// 下拉刷新
const handleRefresh = useCallback(() => {
loadData(currentDate, true);
}, [currentDate, loadData]);
// 切换日期
const handleDateChange = useCallback((days) => {
const newDate = new Date(currentDate);
newDate.setDate(newDate.getDate() + days);
// 不能超过今天
if (newDate <= new Date()) {
setCurrentDate(newDate);
}
}, [currentDate]);
// 点击板块
const handleSectorPress = useCallback((sector) => {
navigation.navigate('SectorDetail', {
sectorName: sector.name,
date: formatApiDate(currentDate),
});
}, [navigation, currentDate]);
// 点击股票
const handleStockPress = useCallback((stock) => {
navigation.navigate('StockDetail', {
stockCode: stock.scode,
stockName: stock.sname,
});
}, [navigation]);
// 获取股票列表
const stocks = ztData?.stocks || ztData?.stock_infos || [];
// 按连板天数排序
const sortedStocks = [...stocks].sort((a, b) =>
parseContinuousDays(b.continuous_days) - parseContinuousDays(a.continuous_days)
);
// 渲染头部
const renderHeader = () => (
<VStack>
{/* 标题栏 */}
<Box px={4} pt={2} pb={2}>
<HStack alignItems="center" justifyContent="space-between">
<VStack>
<Text fontSize="2xl" fontWeight="bold" color="white">
市场热点
</Text>
<Text fontSize="sm" color="gray.400">
涨停板块与个股分析
</Text>
</VStack>
<HStack space={2}>
<Pressable
onPress={() => navigation.navigate('TodayStats')}
p={2}
rounded="full"
bg="rgba(255,255,255,0.1)"
>
<Icon as={Ionicons} name="stats-chart" size="md" color="white" />
</Pressable>
<Pressable
onPress={() => navigation.navigate('EventCalendar')}
p={2}
rounded="full"
bg="rgba(255,255,255,0.1)"
>
<Icon as={Ionicons} name="calendar" size="md" color="white" />
</Pressable>
<Pressable
onPress={() => navigation.openDrawer?.()}
p={2}
rounded="full"
bg="rgba(255,255,255,0.1)"
>
<Icon as={Ionicons} name="menu" size="md" color="white" />
</Pressable>
</HStack>
</HStack>
</Box>
{/* 日期选择器 */}
<Box px={4} pt={2} pb={3}>
<HStack alignItems="center" justifyContent="space-between">
<Pressable
onPress={() => handleDateChange(-1)}
p={2}
rounded="full"
bg="rgba(255,255,255,0.05)"
>
<Icon as={Ionicons} name="chevron-back" size="sm" color="gray.400" />
</Pressable>
<Pressable onPress={() => setCurrentDate(new Date())}>
<VStack alignItems="center">
<Text fontSize="lg" fontWeight="bold" color="white">
{formatDisplayDate(currentDate)}
</Text>
<Text fontSize="xs" color="gray.500">
点击返回今日
</Text>
</VStack>
</Pressable>
<Pressable
onPress={() => handleDateChange(1)}
p={2}
rounded="full"
bg="rgba(255,255,255,0.05)"
opacity={currentDate.toDateString() === new Date().toDateString() ? 0.3 : 1}
disabled={currentDate.toDateString() === new Date().toDateString()}
>
<Icon as={Ionicons} name="chevron-forward" size="sm" color="gray.400" />
</Pressable>
</HStack>
</Box>
{/* 今日概览卡片 */}
{renderOverviewCard()}
{/* 热门板块 */}
{renderHotSectors()}
{/* 连板龙头 */}
{renderContinuousLeaders()}
{/* 涨停个股标题 */}
<HStack px={4} py={3} alignItems="center" justifyContent="space-between">
<HStack alignItems="center">
<LinearGradient
colors={gradients.secondary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="list" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
涨停个股
</Text>
</HStack>
<Text fontSize="xs" color="gray.500">
{stocks.length}
</Text>
</HStack>
</VStack>
);
// 渲染今日概览
const renderOverviewCard = () => {
if (!stats) return null;
return (
<Box mx={4} mb={4} rounded="3xl" overflow="hidden">
<LinearGradient
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.overviewGradient}
>
<Box
borderWidth={1}
borderColor="rgba(212, 175, 55, 0.3)"
rounded="3xl"
p={4}
>
{/* 总涨停数 */}
<HStack alignItems="center" justifyContent="center" mb={4}>
<Icon as={Ionicons} name="flash" size="md" color="#D4AF37" mr={2} />
<Text fontSize="4xl" fontWeight="bold" color="#D4AF37">
{stats.total}
</Text>
<Text fontSize="md" color="#D4AF37" ml={1}>
只涨停
</Text>
</HStack>
{/* 统计网格 */}
<HStack justifyContent="space-around">
{/* 连板分布 */}
<VStack alignItems="center">
<Text fontSize="2xs" color="gray.500" mb={1}>连板分布</Text>
<HStack space={1}>
{Object.entries(stats.continuousStats || {})
.sort((a, b) => parseContinuousDays(b[0]) - parseContinuousDays(a[0]))
.slice(0, 4)
.map(([key, count]) => (
<Box
key={key}
bg="rgba(255,255,255,0.05)"
rounded="md"
px={1.5}
py={0.5}
>
<Text fontSize="2xs" color="gray.400">
{key}:{count}
</Text>
</Box>
))
}
</HStack>
</VStack>
{/* 时间分布 */}
<VStack alignItems="center">
<Text fontSize="2xs" color="gray.500" mb={1}>时间分布</Text>
<HStack space={1}>
<Box bg="rgba(16, 185, 129, 0.2)" rounded="md" px={1.5} py={0.5}>
<Text fontSize="2xs" color="#10B981">
秒板:{stats.timeStats?.['秒板'] || 0}
</Text>
</Box>
<Box bg="rgba(251, 146, 60, 0.2)" rounded="md" px={1.5} py={0.5}>
<Text fontSize="2xs" color="#FB923C">
尾盘:{stats.timeStats?.['尾盘'] || 0}
</Text>
</Box>
</HStack>
</VStack>
</HStack>
</Box>
</LinearGradient>
</Box>
);
};
// 渲染热门板块
const renderHotSectors = () => {
if (hotSectors.length === 0) return null;
return (
<VStack mb={4}>
<HStack px={4} mb={3} alignItems="center">
<LinearGradient
colors={gradients.danger}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="flame" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
热门板块
</Text>
</HStack>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
>
<HStack space={3}>
{hotSectors.map((sector, index) => {
const heatColor = getHeatColor(sector.count);
return (
<Pressable
key={sector.name}
onPress={() => handleSectorPress(sector)}
>
<Box
bg={heatColor.bg}
borderWidth={1}
borderColor={heatColor.border}
rounded="2xl"
p={3}
w={SCREEN_WIDTH * 0.35}
>
{/* 排名 */}
<Box
position="absolute"
top={-8}
left={-8}
bg={index < 3 ? '#D4AF37' : 'gray.600'}
rounded="full"
w={6}
h={6}
alignItems="center"
justifyContent="center"
>
<Text fontSize="xs" fontWeight="bold" color="white">
{index + 1}
</Text>
</Box>
<Text
fontSize="sm"
fontWeight="bold"
color="white"
numberOfLines={1}
mb={1}
>
{sector.name}
</Text>
<HStack alignItems="baseline">
<Text fontSize="xl" fontWeight="bold" color={heatColor.text}>
{sector.count}
</Text>
<Text fontSize="xs" color="gray.500" ml={1}>
只涨停
</Text>
</HStack>
{sector.related_events?.length > 0 && (
<HStack alignItems="center" mt={1}>
<Icon as={Ionicons} name="flash-outline" size="2xs" color="gray.500" />
<Text fontSize="2xs" color="gray.500" ml={0.5}>
{sector.related_events.length}个驱动事件
</Text>
</HStack>
)}
</Box>
</Pressable>
);
})}
</HStack>
</ScrollView>
</VStack>
);
};
// 渲染连板龙头
const renderContinuousLeaders = () => {
const leaders = ztService.getContinuousLeaders(ztData, 2);
if (leaders.length === 0) return null;
return (
<VStack mb={4}>
<HStack px={4} mb={3} alignItems="center">
<LinearGradient
colors={gradients.warning}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="trophy" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
连板龙头
</Text>
</HStack>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
>
<HStack space={3}>
{leaders.slice(0, 8).map((stock) => {
const days = parseContinuousDays(stock.continuous_days);
const color = getContinuousColor(days);
return (
<Pressable
key={stock.scode}
onPress={() => handleStockPress(stock)}
>
<Box
bg="rgba(255, 255, 255, 0.03)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
rounded="2xl"
p={3}
minW={24}
alignItems="center"
>
{/* 连板标签 */}
<Box
bg={`${color}20`}
borderWidth={1}
borderColor={`${color}50`}
rounded="full"
px={2}
py={0.5}
mb={2}
>
<Text fontSize="xs" fontWeight="bold" color={color}>
{stock.continuous_days}
</Text>
</Box>
<Text
fontSize="sm"
fontWeight="bold"
color="white"
numberOfLines={1}
>
{stock.sname}
</Text>
<Text fontSize="2xs" color="gray.500">
{stock.scode}
</Text>
</Box>
</Pressable>
);
})}
</HStack>
</ScrollView>
</VStack>
);
};
// 渲染股票项
const renderStockItem = ({ item: stock, index }) => {
const days = parseContinuousDays(stock.continuous_days);
const color = getContinuousColor(days);
return (
<Pressable
onPress={() => handleStockPress(stock)}
mx={4}
mb={2}
>
<Box
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={3.5}
>
<HStack alignItems="center">
{/* 序号 */}
<Box w={8} alignItems="center">
<Text fontSize="sm" color="gray.500" fontWeight="semibold">
{index + 1}
</Text>
</Box>
{/* 股票信息 */}
<VStack flex={1} ml={2}>
<HStack alignItems="center">
<Text fontSize="sm" fontWeight="bold" color="white">
{stock.sname}
</Text>
{days > 1 && (
<Box
bg={`${color}20`}
borderWidth={1}
borderColor={`${color}40`}
rounded="md"
px={1.5}
py={0.5}
ml={2}
>
<Text fontSize="2xs" fontWeight="bold" color={color}>
{stock.continuous_days}
</Text>
</Box>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
{stock.scode}
</Text>
</VStack>
{/* 涨停时间 */}
<VStack alignItems="flex-end" mr={3}>
<Text fontSize="xs" color="gray.400">
{stock.formatted_time || stock.zt_time || '--:--'}
</Text>
{stock.is_announcement && (
<HStack alignItems="center">
<Icon as={Ionicons} name="document-text" size="2xs" color="warning.400" />
<Text fontSize="2xs" color="warning.400" ml={0.5}>
公告
</Text>
</HStack>
)}
</VStack>
{/* 箭头 */}
<Icon as={Ionicons} name="chevron-forward" size="sm" color="gray.600" />
</HStack>
{/* 涨停原因 */}
{stock.zt_reason && (
<Box
mt={2}
pt={2}
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.05)"
>
<Text fontSize="xs" color="gray.400" numberOfLines={2}>
{stock.zt_reason}
</Text>
</Box>
)}
</Box>
</Pressable>
);
};
// 渲染空状态
const renderEmpty = () => {
if (loading) {
return (
<Center flex={1} py={20}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载涨停数据...
</Text>
</Center>
);
}
return (
<Center flex={1} py={20}>
<Icon as={Ionicons} name="bar-chart-outline" size="4xl" color="gray.600" mb={4} />
<Text fontSize="md" color="gray.400">
暂无涨停数据
</Text>
<Text fontSize="sm" color="gray.600" mt={1}>
可能是非交易日或数据未更新
</Text>
</Center>
);
};
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<FlatList
data={sortedStocks}
renderItem={renderStockItem}
keyExtractor={(item) => item.scode}
ListHeaderComponent={renderHeader}
ListEmptyComponent={renderEmpty}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#D4AF37"
colors={['#D4AF37']}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: insets.bottom + 20,
}}
/>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
overviewGradient: {
borderRadius: 24,
},
});
export default MarketHot;

View File

@@ -0,0 +1,360 @@
/**
* 板块详情页面 - HeroUI 风格
* 展示某个板块的涨停股票和关联事件
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
StatusBar,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ztService from '../../services/ztService';
import { gradients } from '../../theme';
// 连板颜色
const getContinuousColor = (days) => {
if (days >= 5) return '#A78BFA';
if (days >= 3) return '#F43F5E';
if (days >= 2) return '#FB923C';
return '#64748B';
};
// 解析连板天数
const parseContinuousDays = (str) => {
if (!str || str === '首板') return 1;
const match = str.match(/(\d+)/);
return match ? parseInt(match[1]) : 1;
};
const SectorDetail = ({ route, navigation }) => {
const { sectorName, date } = route.params;
const insets = useSafeAreaInsets();
const [sectorData, setSectorData] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 加载数据
const loadData = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
const result = await ztService.getSectorDetail(date, sectorName);
if (result.success) {
setSectorData(result.data);
}
} catch (error) {
console.error('加载板块详情失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [date, sectorName]);
// 初始加载
useEffect(() => {
loadData();
}, [loadData]);
// 设置导航标题
useEffect(() => {
navigation.setOptions({
title: sectorName,
});
}, [navigation, sectorName]);
// 点击股票
const handleStockPress = useCallback((stock) => {
navigation.navigate('StockDetail', {
stockCode: stock.scode,
stockName: stock.sname,
});
}, [navigation]);
// 点击事件
const handleEventPress = useCallback((event) => {
navigation.navigate('EventDetail', {
eventId: event.id,
title: event.title,
});
}, [navigation]);
// 排序股票
const sortedStocks = sectorData?.stocks
? [...sectorData.stocks].sort((a, b) =>
parseContinuousDays(b.continuous_days) - parseContinuousDays(a.continuous_days)
)
: [];
// 渲染头部
const renderHeader = () => (
<VStack>
{/* 板块概览 */}
<Box mx={4} mt={4} mb={4} rounded="3xl" overflow="hidden">
<LinearGradient
colors={['rgba(124, 58, 237, 0.15)', 'rgba(236, 72, 153, 0.1)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.headerGradient}
>
<Box
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
<HStack alignItems="center" justifyContent="center" mb={3}>
<Icon as={Ionicons} name="trending-up" size="lg" color="primary.400" mr={2} />
<Text fontSize="3xl" fontWeight="bold" color="white">
{sectorData?.count || 0}
</Text>
<Text fontSize="md" color="gray.400" ml={1}>
只涨停
</Text>
</HStack>
{sectorData?.related_events?.length > 0 && (
<HStack alignItems="center" justifyContent="center">
<Icon as={Ionicons} name="flash" size="sm" color="warning.400" />
<Text fontSize="sm" color="warning.400" ml={1}>
{sectorData.related_events.length} 个驱动事件
</Text>
</HStack>
)}
</Box>
</LinearGradient>
</Box>
{/* 关联事件 */}
{sectorData?.related_events?.length > 0 && (
<VStack mx={4} mb={4}>
<HStack alignItems="center" mb={3}>
<LinearGradient
colors={gradients.warning}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="flash" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
驱动事件
</Text>
</HStack>
<VStack space={2}>
{sectorData.related_events.map((event, index) => (
<Pressable
key={event.id || index}
onPress={() => handleEventPress(event)}
>
<Box
bg="rgba(249, 115, 22, 0.1)"
borderWidth={1}
borderColor="rgba(249, 115, 22, 0.2)"
rounded="2xl"
p={3}
>
<Text fontSize="sm" fontWeight="semibold" color="white" numberOfLines={2}>
{event.title}
</Text>
{event.description && (
<Text fontSize="xs" color="gray.400" numberOfLines={2} mt={1}>
{event.description}
</Text>
)}
</Box>
</Pressable>
))}
</VStack>
</VStack>
)}
{/* 涨停个股标题 */}
<HStack px={4} py={3} alignItems="center" justifyContent="space-between">
<HStack alignItems="center">
<LinearGradient
colors={gradients.secondary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.iconBadge}
>
<Icon as={Ionicons} name="list" size="xs" color="white" />
</LinearGradient>
<Text fontSize="md" fontWeight="bold" color="white" ml={2}>
涨停个股
</Text>
</HStack>
</HStack>
</VStack>
);
// 渲染股票项
const renderStockItem = ({ item: stock, index }) => {
const days = parseContinuousDays(stock.continuous_days);
const color = getContinuousColor(days);
return (
<Pressable
onPress={() => handleStockPress(stock)}
mx={4}
mb={2}
>
<Box
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={3.5}
>
<HStack alignItems="center">
{/* 序号 */}
<Box w={8} alignItems="center">
<Text fontSize="sm" color="gray.500" fontWeight="semibold">
{index + 1}
</Text>
</Box>
{/* 股票信息 */}
<VStack flex={1} ml={2}>
<HStack alignItems="center">
<Text fontSize="sm" fontWeight="bold" color="white">
{stock.sname}
</Text>
{days > 1 && (
<Box
bg={`${color}20`}
borderWidth={1}
borderColor={`${color}40`}
rounded="md"
px={1.5}
py={0.5}
ml={2}
>
<Text fontSize="2xs" fontWeight="bold" color={color}>
{stock.continuous_days}
</Text>
</Box>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
{stock.scode}
</Text>
</VStack>
{/* 涨停时间 */}
<VStack alignItems="flex-end" mr={3}>
<Text fontSize="xs" color="gray.400">
{stock.formatted_time || stock.zt_time || '--:--'}
</Text>
{stock.is_announcement && (
<HStack alignItems="center">
<Icon as={Ionicons} name="document-text" size="2xs" color="warning.400" />
<Text fontSize="2xs" color="warning.400" ml={0.5}>
公告
</Text>
</HStack>
)}
</VStack>
{/* 箭头 */}
<Icon as={Ionicons} name="chevron-forward" size="sm" color="gray.600" />
</HStack>
{/* 涨停原因 */}
{stock.zt_reason && (
<Box
mt={2}
pt={2}
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.05)"
>
<Text fontSize="xs" color="gray.400" numberOfLines={2}>
{stock.zt_reason}
</Text>
</Box>
)}
</Box>
</Pressable>
);
};
// 加载状态
if (loading) {
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<Center flex={1}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>
加载板块详情...
</Text>
</Center>
</Box>
);
}
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<FlatList
data={sortedStocks}
renderItem={renderStockItem}
keyExtractor={(item) => item.scode}
ListHeaderComponent={renderHeader}
ListEmptyComponent={
<Center py={10}>
<Text fontSize="sm" color="gray.500">暂无涨停股票</Text>
</Center>
}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadData(true)}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: insets.bottom + 20,
}}
/>
</Box>
);
};
const styles = StyleSheet.create({
iconBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
headerGradient: {
borderRadius: 24,
},
});
export default SectorDetail;

View File

@@ -0,0 +1,158 @@
/**
* 股票详情页面 - HeroUI 风格
* 展示个股详细信息(占位版本)
*/
import React from 'react';
import {
StyleSheet,
StatusBar,
ScrollView,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { gradients } from '../../theme';
const StockDetail = ({ route, navigation }) => {
const { stockCode, stockName } = route.params;
const insets = useSafeAreaInsets();
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: insets.bottom + 20,
}}
>
{/* 标题栏 */}
<HStack px={4} py={3} alignItems="center">
<Pressable
onPress={() => navigation.goBack()}
p={2}
mr={2}
rounded="full"
bg="rgba(255,255,255,0.05)"
>
<Icon as={Ionicons} name="arrow-back" size="md" color="white" />
</Pressable>
<VStack flex={1}>
<Text fontSize="xl" fontWeight="bold" color="white">
{stockName}
</Text>
<Text fontSize="sm" color="gray.500">
{stockCode}
</Text>
</VStack>
</HStack>
{/* 股票信息卡片 */}
<Box mx={4} my={4} rounded="3xl" overflow="hidden">
<LinearGradient
colors={['rgba(124, 58, 237, 0.15)', 'rgba(236, 72, 153, 0.1)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cardGradient}
>
<Box
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="3xl"
p={5}
>
<Center py={10}>
<Icon
as={Ionicons}
name="construct-outline"
size="4xl"
color="primary.400"
mb={4}
/>
<Text fontSize="lg" fontWeight="bold" color="white" mb={2}>
功能开发中
</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
股票详情页面正在开发中{'\n'}
将提供K线图财务数据相关新闻等信息
</Text>
</Center>
</Box>
</LinearGradient>
</Box>
{/* 即将推出的功能 */}
<VStack mx={4} space={3}>
<Text fontSize="md" fontWeight="bold" color="white" mb={2}>
即将推出
</Text>
{[
{ icon: 'trending-up', title: 'K线图表', desc: '分时、日K、周K、月K图表' },
{ icon: 'document-text', title: '基本面', desc: '财务报表、估值指标、盈利分析' },
{ icon: 'newspaper', title: '相关新闻', desc: '个股资讯、公告、研报' },
{ icon: 'git-network', title: '关联分析', desc: '板块关系、资金流向、机构持仓' },
].map((item, index) => (
<Box
key={index}
bg="rgba(30, 41, 59, 0.6)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.05)"
rounded="2xl"
p={4}
>
<HStack alignItems="center">
<Box
bg="rgba(124, 58, 237, 0.2)"
rounded="xl"
p={2.5}
mr={3}
>
<Icon as={Ionicons} name={item.icon} size="sm" color="primary.400" />
</Box>
<VStack flex={1}>
<Text fontSize="sm" fontWeight="semibold" color="white">
{item.title}
</Text>
<Text fontSize="xs" color="gray.500">
{item.desc}
</Text>
</VStack>
<Box
bg="rgba(251, 146, 60, 0.2)"
rounded="full"
px={2}
py={0.5}
>
<Text fontSize="2xs" color="warning.400">
开发中
</Text>
</Box>
</HStack>
</Box>
))}
</VStack>
</ScrollView>
</Box>
);
};
const styles = StyleSheet.create({
cardGradient: {
borderRadius: 24,
},
});
export default StockDetail;

View File

@@ -0,0 +1,588 @@
/**
* 今日统计面板 - HeroUI 风格
* 展示事件胜率、市场统计、TOP10排行榜
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
ScrollView,
StyleSheet,
StatusBar,
Dimensions,
TouchableOpacity,
RefreshControl,
} from 'react-native';
import {
Box,
VStack,
HStack,
Text,
Icon,
Pressable,
Spinner,
Center,
} from 'native-base';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Circle, Defs, LinearGradient as SvgLinearGradient, Stop } from 'react-native-svg';
import eventService from '../../services/eventService';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 格式化涨跌幅
const formatChg = (val) => {
if (val === null || val === undefined) return '-';
const num = parseFloat(val);
if (isNaN(num)) return '-';
return (num >= 0 ? '+' : '') + num.toFixed(2) + '%';
};
// 获取涨跌幅颜色
const getChgColor = (val) => {
if (val === null || val === undefined) return '#9CA3AF';
const num = parseFloat(val);
if (isNaN(num)) return '#9CA3AF';
if (num > 0) return '#EF4444';
if (num < 0) return '#22C55E';
return '#9CA3AF';
};
// 获取胜率颜色
const getRateColor = (rate) => {
if (rate >= 50) return '#F43F5E'; // 红色
return '#22C55E'; // 绿色
};
// 圆环进度图组件
const CircularGauge = ({ rate, label, iconName }) => {
const validRate = Math.min(100, Math.max(0, rate || 0));
const gaugeColor = getRateColor(validRate);
const radius = 42;
const strokeWidth = 8;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (validRate / 100) * circumference;
return (
<Box
flex={1}
bg="rgba(255,255,255,0.03)"
borderRadius="2xl"
p={4}
borderWidth={1}
borderColor="rgba(255,255,255,0.08)"
>
<Center>
<Box position="relative" w="100px" h="100px">
<Svg width={100} height={100} style={{ transform: [{ rotate: '-90deg' }] }}>
<Defs>
<SvgLinearGradient id={`gauge-grad-${label}`} x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={gaugeColor} stopOpacity="0.6" />
<Stop offset="100%" stopColor={gaugeColor} stopOpacity="1" />
</SvgLinearGradient>
</Defs>
{/* 背景圆环 */}
<Circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="rgba(255,255,255,0.08)"
strokeWidth={strokeWidth}
/>
{/* 进度圆环 */}
<Circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke={`url(#gauge-grad-${label})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
/>
</Svg>
{/* 中心数值 */}
<VStack
position="absolute"
top="50%"
left="50%"
style={{ transform: [{ translateX: -25 }, { translateY: -20 }] }}
alignItems="center"
>
<Text
fontSize="2xl"
fontWeight="bold"
color={gaugeColor}
lineHeight="28px"
>
{validRate.toFixed(1)}
</Text>
<Text fontSize="xs" color="gray.500">%</Text>
</VStack>
</Box>
</Center>
{/* 标签 */}
<HStack justifyContent="center" mt={2} space={2} alignItems="center">
<Icon as={Ionicons} name={iconName} size="sm" color={gaugeColor} />
<Text fontSize="sm" color="gray.300" fontWeight="medium">
{label}
</Text>
</HStack>
</Box>
);
};
// 涨跌统计条组件
const MarketStatsBar = ({ marketStats }) => {
if (!marketStats || marketStats.totalCount <= 0) return null;
const risingPercent = (marketStats.risingCount / marketStats.totalCount) * 100;
const flatPercent = (marketStats.flatCount / marketStats.totalCount) * 100;
return (
<Box
bg="rgba(255,255,255,0.03)"
borderRadius="xl"
p={3}
borderWidth={1}
borderColor="rgba(255,255,255,0.06)"
>
<HStack justifyContent="space-between" mb={2}>
<Text fontSize="xs" color="gray.500">沪深两市实时</Text>
<Text fontSize="xs" color="gray.600">{marketStats.totalCount} </Text>
</HStack>
{/* 进度条 */}
<Box position="relative" h="6px" borderRadius="full" overflow="hidden" bg="rgba(255,255,255,0.05)">
{/* 涨 */}
<Box
position="absolute"
left="0"
top="0"
h="100%"
w={`${risingPercent}%`}
borderRadius="full"
>
<LinearGradient
colors={['#F43F5E', '#FB7185']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Box>
{/* 平 */}
<Box
position="absolute"
left={`${risingPercent}%`}
top="0"
h="100%"
w={`${flatPercent}%`}
bg="rgba(255,255,255,0.3)"
/>
</Box>
{/* 数字统计 */}
<HStack justifyContent="space-between" mt={2}>
<HStack space={1} alignItems="center">
<Box w={2} h={2} borderRadius="full" bg="#F43F5E" />
<Text fontSize="sm" color="#FB7185" fontWeight="bold">{marketStats.risingCount}</Text>
<Text fontSize="xs" color="gray.500"></Text>
</HStack>
<HStack space={1} alignItems="center">
<Box w={2} h={2} borderRadius="full" bg="gray.400" />
<Text fontSize="sm" color="gray.400" fontWeight="bold">{marketStats.flatCount}</Text>
<Text fontSize="xs" color="gray.500"></Text>
</HStack>
<HStack space={1} alignItems="center">
<Box w={2} h={2} borderRadius="full" bg="#22C55E" />
<Text fontSize="sm" color="#22C55E" fontWeight="bold">{marketStats.fallingCount}</Text>
<Text fontSize="xs" color="gray.500"></Text>
</HStack>
</HStack>
</Box>
);
};
// 统计卡片组件
const StatCard = ({ label, value, iconName, color }) => (
<Box
flex={1}
bg="rgba(255,255,255,0.03)"
borderRadius="xl"
p={3}
borderWidth={1}
borderColor="rgba(255,255,255,0.06)"
>
<HStack space={2} mb={1} alignItems="center">
<Box
p={1.5}
borderRadius="lg"
bg={`${color}20`}
>
<Icon as={Ionicons} name={iconName} size="xs" color={color} />
</Box>
<Text fontSize="2xs" color="gray.500" fontWeight="medium">
{label}
</Text>
</HStack>
<Text fontSize="lg" fontWeight="bold" color={color}>
{value}
</Text>
</Box>
);
// TOP 事件项
const TopEventItem = ({ event, rank, onPress }) => (
<TouchableOpacity onPress={() => onPress?.(event)} activeOpacity={0.7}>
<HStack
space={2}
py={2}
px={3}
bg="rgba(0,0,0,0.2)"
borderRadius="lg"
alignItems="center"
>
<Box
w={5}
h={5}
borderRadius="full"
bg={rank === 1 ? '#FFD700' : rank === 2 ? '#C0C0C0' : rank === 3 ? '#CD7F32' : 'gray.600'}
alignItems="center"
justifyContent="center"
>
<Text fontSize="2xs" fontWeight="bold" color={rank <= 3 ? '#1a1a2e' : 'white'}>
{rank}
</Text>
</Box>
<Text fontSize="xs" color="gray.300" flex={1} numberOfLines={1}>
{event.title}
</Text>
<Text fontSize="xs" fontWeight="bold" color={getChgColor(event.avgChg)}>
{formatChg(event.avgChg)}
</Text>
</HStack>
</TouchableOpacity>
);
// TOP 股票项
const TopStockItem = ({ stock, rank }) => (
<HStack
space={2}
py={2}
px={3}
bg="rgba(0,0,0,0.2)"
borderRadius="lg"
alignItems="center"
>
<Box
w={5}
h={5}
borderRadius="full"
bg={rank === 1 ? '#FFD700' : rank === 2 ? '#C0C0C0' : rank === 3 ? '#CD7F32' : 'gray.600'}
alignItems="center"
justifyContent="center"
>
<Text fontSize="2xs" fontWeight="bold" color={rank <= 3 ? '#1a1a2e' : 'white'}>
{rank}
</Text>
</Box>
<Text fontSize="2xs" color="gray.500" w="50px">
{stock.stockCode?.split('.')[0] || '-'}
</Text>
<Text fontSize="xs" color="gray.300" flex={1} numberOfLines={1}>
{stock.stockName || '-'}
</Text>
<Text fontSize="xs" fontWeight="bold" color={getChgColor(stock.maxChg)}>
{formatChg(stock.maxChg)}
</Text>
</HStack>
);
const TodayStats = ({ navigation }) => {
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [stats, setStats] = useState(null);
const [activeTab, setActiveTab] = useState('events'); // 'events' | 'stocks'
// 加载数据
const loadStats = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
const result = await eventService.getEffectivenessStats(1);
if (result.success || result.code === 200) {
setStats(result.data);
}
} catch (error) {
console.error('获取统计数据失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadStats();
}, [loadStats]);
// 点击事件
const handleEventPress = useCallback((event) => {
if (event.id) {
navigation.navigate('EventDetail', {
eventId: event.id,
title: event.title,
});
}
}, [navigation]);
if (loading) {
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<Center flex={1}>
<Spinner size="lg" color="primary.500" />
<Text fontSize="sm" color="gray.500" mt={4}>加载统计数据...</Text>
</Center>
</Box>
);
}
const { summary, marketStats, topPerformers = [], topStocks = [] } = stats || {};
return (
<Box flex={1} bg="#0F172A">
<StatusBar barStyle="light-content" />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: insets.bottom + 20,
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadStats(true)}
tintColor="#7C3AED"
colors={['#7C3AED']}
/>
}
>
{/* 标题栏 */}
<Box px={4} pt={4} pb={2}>
<HStack alignItems="center" justifyContent="space-between">
<HStack alignItems="center">
<Pressable
onPress={() => navigation.goBack()}
p={2}
mr={2}
rounded="full"
bg="rgba(255,255,255,0.05)"
>
<Icon as={Ionicons} name="arrow-back" size="md" color="white" />
</Pressable>
<VStack>
<HStack alignItems="center" space={2}>
<Text fontSize="xl" fontWeight="bold" color="white">
今日统计
</Text>
<Box
px={2}
py={0.5}
bg="rgba(34, 197, 94, 0.15)"
borderWidth={1}
borderColor="rgba(34, 197, 94, 0.3)"
borderRadius="full"
>
<HStack space={1} alignItems="center">
<Box w={1.5} h={1.5} borderRadius="full" bg="#22C55E" />
<Text fontSize="2xs" color="#22C55E" fontWeight="medium">实时</Text>
</HStack>
</Box>
</HStack>
<Text fontSize="xs" color="gray.500">
事件胜率 · 市场统计 · TOP排行
</Text>
</VStack>
</HStack>
<Pressable
onPress={() => navigation.openDrawer?.()}
p={2}
rounded="full"
bg="rgba(255,255,255,0.1)"
>
<Icon as={Ionicons} name="menu" size="md" color="white" />
</Pressable>
</HStack>
</Box>
{/* 双圆环仪表盘 */}
<Box mx={4} mt={4}>
<HStack space={4}>
<CircularGauge
rate={summary?.positiveRate || 0}
label="事件胜率"
iconName="trophy"
/>
<CircularGauge
rate={marketStats?.risingRate || 0}
label="大盘上涨率"
iconName="trending-up"
/>
</HStack>
</Box>
{/* 涨跌统计条 */}
<Box mx={4} mt={4}>
<MarketStatsBar marketStats={marketStats} />
</Box>
{/* 核心指标 2x2 网格 */}
<Box mx={4} mt={4}>
<VStack space={3}>
<HStack space={3}>
<StatCard
label="事件数"
value={summary?.totalEvents || 0}
iconName="flame"
color="#F59E0B"
/>
<StatCard
label="关联股票"
value={summary?.totalStocks || 0}
iconName="stats-chart"
color="#06B6D4"
/>
</HStack>
<HStack space={3}>
<StatCard
label="平均超额"
value={formatChg(summary?.avgChg)}
iconName="trending-up"
color={summary?.avgChg >= 0 ? '#F43F5E' : '#22C55E'}
/>
<StatCard
label="最大超额"
value={formatChg(summary?.maxChg)}
iconName="flash"
color="#F43F5E"
/>
</HStack>
</VStack>
</Box>
{/* 分割线 */}
<Box mx={4} my={4} h="1px" bg="rgba(255,255,255,0.06)" />
{/* TOP 排行榜标签页 */}
<Box mx={4}>
{/* Tab 切换 */}
<HStack space={2} mb={3}>
<Pressable
onPress={() => setActiveTab('events')}
flex={1}
>
<Box
py={2}
bg={activeTab === 'events' ? 'rgba(255,215,0,0.2)' : 'rgba(255,255,255,0.03)'}
borderRadius="lg"
borderWidth={1}
borderColor={activeTab === 'events' ? 'rgba(255,215,0,0.4)' : 'rgba(255,255,255,0.06)'}
alignItems="center"
>
<HStack space={1} alignItems="center">
<Icon
as={Ionicons}
name="trophy"
size="xs"
color={activeTab === 'events' ? '#FFD700' : 'gray.500'}
/>
<Text
fontSize="xs"
fontWeight="600"
color={activeTab === 'events' ? '#FFD700' : 'gray.500'}
>
事件TOP10
</Text>
</HStack>
</Box>
</Pressable>
<Pressable
onPress={() => setActiveTab('stocks')}
flex={1}
>
<Box
py={2}
bg={activeTab === 'stocks' ? 'rgba(255,215,0,0.2)' : 'rgba(255,255,255,0.03)'}
borderRadius="lg"
borderWidth={1}
borderColor={activeTab === 'stocks' ? 'rgba(255,215,0,0.4)' : 'rgba(255,255,255,0.06)'}
alignItems="center"
>
<HStack space={1} alignItems="center">
<Icon
as={Ionicons}
name="bar-chart"
size="xs"
color={activeTab === 'stocks' ? '#FFD700' : 'gray.500'}
/>
<Text
fontSize="xs"
fontWeight="600"
color={activeTab === 'stocks' ? '#FFD700' : 'gray.500'}
>
股票TOP10
</Text>
</HStack>
</Box>
</Pressable>
</HStack>
{/* 列表内容 */}
<VStack space={2}>
{activeTab === 'events' ? (
topPerformers.length > 0 ? (
topPerformers.slice(0, 10).map((event, idx) => (
<TopEventItem
key={event.id || idx}
event={event}
rank={idx + 1}
onPress={handleEventPress}
/>
))
) : (
<Center py={6}>
<Text fontSize="sm" color="gray.600">暂无事件数据</Text>
</Center>
)
) : (
topStocks.length > 0 ? (
topStocks.slice(0, 10).map((stock, idx) => (
<TopStockItem
key={stock.stockCode || idx}
stock={stock}
rank={idx + 1}
/>
))
) : (
<Center py={6}>
<Text fontSize="sm" color="gray.600">暂无股票数据</Text>
</Center>
)
)}
</VStack>
</Box>
</ScrollView>
</Box>
);
};
export default TodayStats;

View File

@@ -0,0 +1,9 @@
/**
* 市场模块导出
*/
export { default as MarketHot } from './MarketHot';
export { default as SectorDetail } from './SectorDetail';
export { default as EventCalendar } from './EventCalendar';
export { default as StockDetail } from './StockDetail';
export { default as TodayStats } from './TodayStats';

View File

@@ -0,0 +1,62 @@
/**
* API 基础配置和请求封装
* 复用 Web 端的 API 逻辑,适配 React Native
*/
const API_BASE_URL = 'https://api.valuefrontier.cn';
// 静态数据存储地址(腾讯 COS
export const API_BASE = 'https://valuefrontier-1308417363.cos-website.ap-shanghai.myqcloud.com';
/**
* 通用 API 请求函数
* @param {string} url - API 路径
* @param {object} options - 请求选项
* @returns {Promise<object>} 响应数据
*/
export const apiRequest = async (url, options = {}) => {
const method = options.method || 'GET';
const fullUrl = `${API_BASE_URL}${url}`;
console.log(`[API] ${method} ${fullUrl}`);
try {
const response = await fetch(fullUrl, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
// 重要:携带 Cookie 以支持 Session 认证
credentials: 'include',
});
// 处理 403 权限不足的情况
if (response.status === 403) {
const errorData = await response.json().catch(() => ({}));
console.warn(`[API] 权限不足: ${fullUrl}`, errorData);
return {
success: false,
error: errorData.error || '需要登录或订阅',
required_level: errorData.required_level,
status: 403,
};
}
if (!response.ok) {
const errorText = await response.text();
console.error(`[API Error] ${method} ${fullUrl}:`, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`[API Response] ${method} ${fullUrl}:`, data.success);
return data;
} catch (error) {
console.error(`[API Error] ${method} ${fullUrl}:`, error.message);
throw error;
}
};
export default apiRequest;

View File

@@ -0,0 +1,175 @@
/**
* 认证服务
* 处理手机号验证码登录、用户状态等
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { apiRequest } from './api';
// 存储键名
const STORAGE_KEYS = {
USER_INFO: '@auth_user_info',
IS_LOGGED_IN: '@auth_is_logged_in',
};
/**
* 认证服务
*/
export const authService = {
/**
* 发送验证码
* @param {string} phone - 手机号
* @returns {Promise<object>} 发送结果
*/
sendVerificationCode: async (phone) => {
try {
console.log('[AuthService] 发送验证码到:', phone);
const response = await apiRequest('/api/auth/send-verification-code', {
method: 'POST',
body: JSON.stringify({
type: 'phone',
credential: phone,
}),
});
console.log('[AuthService] 验证码发送结果:', response.success);
return response;
} catch (error) {
console.error('[AuthService] 发送验证码失败:', error);
throw error;
}
},
/**
* 验证码登录(自动注册)
* @param {string} phone - 手机号
* @param {string} code - 验证码
* @returns {Promise<object>} 登录结果,包含用户信息
*/
loginWithCode: async (phone, code) => {
try {
console.log('[AuthService] 验证码登录:', phone);
const response = await apiRequest('/api/auth/login-with-code', {
method: 'POST',
body: JSON.stringify({
login_type: 'phone',
credential: phone,
verification_code: code,
}),
});
if (response.success && response.user) {
// 保存用户信息到本地存储
await AsyncStorage.setItem(STORAGE_KEYS.USER_INFO, JSON.stringify(response.user));
await AsyncStorage.setItem(STORAGE_KEYS.IS_LOGGED_IN, 'true');
console.log('[AuthService] 登录成功,用户:', response.user.username);
}
return response;
} catch (error) {
console.error('[AuthService] 登录失败:', error);
throw error;
}
},
/**
* 获取当前用户信息
* @returns {Promise<object>} 用户信息
*/
getCurrentUser: async () => {
try {
const response = await apiRequest('/api/account/user-info');
if (response.success && response.user) {
// 更新本地存储
await AsyncStorage.setItem(STORAGE_KEYS.USER_INFO, JSON.stringify(response.user));
await AsyncStorage.setItem(STORAGE_KEYS.IS_LOGGED_IN, 'true');
return response.user;
}
return null;
} catch (error) {
console.error('[AuthService] 获取用户信息失败:', error);
// 可能是未登录,清除本地状态
await authService.clearLocalAuth();
return null;
}
},
/**
* 退出登录
* @returns {Promise<object>} 退出结果
*/
logout: async () => {
try {
console.log('[AuthService] 退出登录');
// 调用后端退出接口
await apiRequest('/api/auth/logout', { method: 'POST' });
} catch (error) {
console.error('[AuthService] 后端退出失败:', error);
} finally {
// 无论后端是否成功,都清除本地状态
await authService.clearLocalAuth();
}
return { success: true };
},
/**
* 清除本地认证状态
*/
clearLocalAuth: async () => {
try {
await AsyncStorage.removeItem(STORAGE_KEYS.USER_INFO);
await AsyncStorage.removeItem(STORAGE_KEYS.IS_LOGGED_IN);
} catch (error) {
console.error('[AuthService] 清除本地状态失败:', error);
}
},
/**
* 获取本地存储的用户信息
* @returns {Promise<object|null>} 用户信息
*/
getStoredUser: async () => {
try {
const userStr = await AsyncStorage.getItem(STORAGE_KEYS.USER_INFO);
return userStr ? JSON.parse(userStr) : null;
} catch (error) {
console.error('[AuthService] 读取本地用户信息失败:', error);
return null;
}
},
/**
* 检查是否已登录(本地状态)
* @returns {Promise<boolean>}
*/
isLoggedIn: async () => {
try {
const isLoggedIn = await AsyncStorage.getItem(STORAGE_KEYS.IS_LOGGED_IN);
return isLoggedIn === 'true';
} catch (error) {
return false;
}
},
/**
* 获取订阅信息
* @returns {Promise<object>} 订阅信息
*/
getSubscription: async () => {
try {
const response = await apiRequest('/api/subscription/current');
return response;
} catch (error) {
console.error('[AuthService] 获取订阅信息失败:', error);
return { success: false, error: error.message };
}
},
};
export default authService;

View File

@@ -0,0 +1,239 @@
/**
* 事件服务层
* 复用 Web 端的 eventService 逻辑
*/
import { apiRequest } from './api';
/**
* 股票服务
* 获取股票实时报价等
*/
export const stockService = {
/**
* 批量获取股票报价
* @param {string[]} codes - 股票代码数组
* @param {string} eventTime - 可选的事件时间
* @returns {Promise<object>} 报价数据 {code: {name, price, change}}
*/
getQuotes: async (codes, eventTime = null) => {
try {
const requestBody = { codes };
if (eventTime) {
requestBody.event_time = eventTime;
}
console.log('[StockService] 获取股票报价:', { codes: codes.slice(0, 5), eventTime });
const response = await apiRequest('/api/stock/quotes', {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (response.success && response.data) {
console.log('[StockService] 报价响应成功:', Object.keys(response.data).length, '只股票');
return response.data;
} else {
console.warn('[StockService] 报价响应格式异常:', response);
return {};
}
} catch (error) {
console.error('[StockService] getQuotes 错误:', error);
throw error;
}
},
};
export const eventService = {
/**
* 获取事件列表
* @param {object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.per_page - 每页数量
* @param {string} params.sort - 排序方式 (new/hot/importance)
* @param {string} params.importance - 重要性筛选 (S/A/B/C)
* @param {string} params.q - 搜索关键词
* @returns {Promise<object>} 事件列表和分页信息
*/
getEvents: (params = {}) => {
// 过滤空值和内部字段
const cleanParams = Object.fromEntries(
Object.entries(params).filter(
([key, v]) => v !== null && v !== undefined && v !== '' && !key.startsWith('_')
)
);
const query = new URLSearchParams(cleanParams).toString();
return apiRequest(`/api/events?${query}`);
},
/**
* 获取热点事件
* @param {object} params - 查询参数
* @returns {Promise<object>} 热点事件列表
*/
getHotEvents: (params = {}) => {
const query = new URLSearchParams(params).toString();
return apiRequest(`/api/events/hot?${query}`);
},
/**
* 获取热门关键词
* @param {number} limit - 返回数量限制
* @returns {Promise<object>} 热门关键词列表
*/
getPopularKeywords: (limit = 20) => {
return apiRequest(`/api/events/keywords/popular?limit=${limit}`);
},
/**
* 获取事件详情
* @param {number} eventId - 事件ID
* @returns {Promise<object>} 事件详情
*/
getEventDetail: async (eventId) => {
return await apiRequest(`/api/events/${eventId}`);
},
/**
* 获取事件相关股票
* @param {number} eventId - 事件ID
* @returns {Promise<object>} 相关股票列表
*/
getRelatedStocks: async (eventId) => {
return await apiRequest(`/api/events/${eventId}/stocks`);
},
/**
* 获取事件相关概念
* @param {number} eventId - 事件ID
* @returns {Promise<object>} 相关概念列表
*/
getRelatedConcepts: async (eventId) => {
return await apiRequest(`/api/events/${eventId}/concepts`);
},
/**
* 切换事件关注状态
* @param {number} eventId - 事件ID
* @param {boolean} isFollowing - 是否关注
* @returns {Promise<object>} 关注状态
*/
toggleFollow: async (eventId, isFollowing) => {
return await apiRequest(`/api/events/${eventId}/follow`, {
method: 'POST',
body: JSON.stringify({ is_following: isFollowing }),
});
},
/**
* 情绪投票(看多/看空)
* @param {number} eventId - 事件ID
* @param {string} voteType - 投票类型 'bullish' | 'bearish' | null
* @returns {Promise<object>} 投票结果
*/
sentimentVote: async (eventId, voteType) => {
return await apiRequest(`/api/events/${eventId}/sentiment-vote`, {
method: 'POST',
body: JSON.stringify({ vote_type: voteType }),
});
},
/**
* 获取历史事件
* @param {number} eventId - 事件ID
* @returns {Promise<object>} 历史事件列表
*/
getHistoricalEvents: async (eventId) => {
return await apiRequest(`/api/events/${eventId}/historical`);
},
/**
* 获取事件帖子
* @param {number} eventId - 事件ID
* @param {object} params - 查询参数
* @returns {Promise<object>} 帖子列表
*/
getEventPosts: async (eventId, params = {}) => {
const query = new URLSearchParams(params).toString();
return await apiRequest(`/api/events/${eventId}/posts?${query}`);
},
/**
* 创建帖子
* @param {number} eventId - 事件ID
* @param {object} data - 帖子数据
* @returns {Promise<object>} 创建结果
*/
createPost: async (eventId, data) => {
return await apiRequest(`/api/events/${eventId}/posts`, {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* 获取传导链数据
* @param {number} eventId - 事件ID
* @returns {Promise<object>} 传导链数据
*/
getTransmissionChain: async (eventId) => {
return await apiRequest(`/api/events/${eventId}/transmission`);
},
/**
* 获取桑基图数据
* @param {number} eventId - 事件ID
* @returns {Promise<object>} 桑基图数据
*/
getSankeyData: async (eventId) => {
return await apiRequest(`/api/events/${eventId}/sankey-data`);
},
/**
* 获取事件有效性统计(今日统计面板数据)
* @param {number} days - 天数
* @param {string} date - 可选日期
* @returns {Promise<object>} 统计数据
*/
getEffectivenessStats: async (days = 1, date = '') => {
const dateParam = date ? `&date=${date}` : '';
return await apiRequest(`/api/v1/events/effectiveness-stats?days=${days}${dateParam}`);
},
/**
* 获取市场实时统计
* @returns {Promise<object>} 市场统计数据
*/
getMarketStats: async () => {
return await apiRequest('/api/v1/market/realtime-stats');
},
/**
* 获取主线/题材事件数据
* @param {object} params - 查询参数
* @param {string} params.group_by - 分组级别 (lv1/lv2/lv3 或具体概念ID)
* @param {number} params.recent_days - 最近天数(与 start_date/end_date 二选一)
* @param {string} params.start_date - 开始时间 (YYYY-MM-DD HH:mm:ss)
* @param {string} params.end_date - 结束时间 (YYYY-MM-DD HH:mm:ss)
* @returns {Promise<object>} 主线事件分组数据
*/
getMainlineEvents: async (params = {}) => {
const { group_by = 'lv1', recent_days, start_date, end_date } = params;
// 构建查询参数
const queryParams = new URLSearchParams();
queryParams.append('group_by', group_by);
// 优先使用 start_date/end_date否则使用 recent_days
if (start_date && end_date) {
queryParams.append('start_date', start_date);
queryParams.append('end_date', end_date);
} else if (recent_days) {
queryParams.append('recent_days', recent_days);
}
return await apiRequest(`/api/events/mainline?${queryParams.toString()}`);
},
};
export default eventService;

View File

@@ -0,0 +1,304 @@
/**
* 涨停数据服务
* 获取涨停板块、个股、统计等数据
*/
import { apiRequest, API_BASE } from './api';
export const ztService = {
/**
* 获取指定日期的涨停数据
* @param {string} dateStr - 日期 YYYYMMDD 格式
* @returns {Promise<object>} 涨停数据
*/
getDailyZt: async (dateStr) => {
try {
// 尝试从静态数据获取
const response = await fetch(`${API_BASE}/data/zt/daily/${dateStr}.json`);
if (response.ok) {
const data = await response.json();
return { success: true, data };
}
return { success: false, message: '数据不存在' };
} catch (error) {
console.error('获取涨停数据失败:', error);
return { success: false, message: error.message };
}
},
/**
* 获取最新的涨停数据(今日或最近交易日)
* @returns {Promise<object>} 最新涨停数据
*/
getLatestZt: async () => {
try {
const response = await fetch(`${API_BASE}/data/zt/latest.json`);
if (response.ok) {
const data = await response.json();
return { success: true, data };
}
// 如果没有 latest.json尝试获取今天的数据
const today = new Date();
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '');
return ztService.getDailyZt(dateStr);
} catch (error) {
console.error('获取最新涨停数据失败:', error);
return { success: false, message: error.message };
}
},
/**
* 获取可用的涨停数据日期列表
* @returns {Promise<object>} 日期列表
*/
getAvailableDates: async () => {
try {
const response = await fetch(`${API_BASE}/data/zt/dates.json`);
if (response.ok) {
const data = await response.json();
return { success: true, data };
}
return { success: false, data: [] };
} catch (error) {
return { success: false, data: [] };
}
},
/**
* 获取板块详情(包含该板块的所有涨停股票)
* @param {string} dateStr - 日期
* @param {string} sectorName - 板块名称
* @returns {Promise<object>} 板块详情
*/
getSectorDetail: async (dateStr, sectorName) => {
const result = await ztService.getDailyZt(dateStr);
if (!result.success) return result;
const sectorData = result.data.sector_data?.[sectorName];
if (!sectorData) {
return { success: false, message: '板块不存在' };
}
// 获取该板块的股票详情
const stocks = result.data.stocks?.filter(s =>
sectorData.stock_codes?.includes(s.scode)
) || [];
return {
success: true,
data: {
name: sectorName,
count: sectorData.count,
stocks,
related_events: sectorData.related_events || [],
}
};
},
/**
* 获取连板统计
* @param {object} ztData - 涨停原始数据
* @returns {object} 统计结果
*/
calculateStats: (ztData) => {
if (!ztData) return null;
const stocks = ztData.stocks || ztData.stock_infos || [];
// 连板统计
const continuousStats = {};
// 时间统计
const timeStats = { '秒板': 0, '早盘': 0, '盘中': 0, '尾盘': 0 };
// 公告驱动统计
let announcementCount = 0;
stocks.forEach(stock => {
// 连板统计
const continuous = stock.continuous_days || '首板';
continuousStats[continuous] = (continuousStats[continuous] || 0) + 1;
// 时间统计
const time = stock.formatted_time || stock.zt_time;
if (time) {
const hour = parseInt(time.split(':')[0]);
const minute = parseInt(time.split(':')[1]);
const totalMinutes = hour * 60 + minute;
if (totalMinutes <= 9 * 60 + 31) {
timeStats['秒板']++;
} else if (totalMinutes <= 10 * 60 + 30) {
timeStats['早盘']++;
} else if (totalMinutes <= 14 * 60) {
timeStats['盘中']++;
} else {
timeStats['尾盘']++;
}
}
// 公告驱动
if (stock.is_announcement) {
announcementCount++;
}
});
return {
total: ztData.total_stocks || stocks.length,
continuousStats,
timeStats,
announcementCount,
announcementRatio: stocks.length > 0
? ((announcementCount / stocks.length) * 100).toFixed(1)
: 0,
};
},
/**
* 获取热门板块排序
* @param {object} ztData - 涨停原始数据
* @param {number} limit - 返回数量
* @returns {Array} 排序后的板块列表
*/
getHotSectors: (ztData, limit = 10) => {
if (!ztData?.sector_data) return [];
// 需要过滤掉的板块名称
const excludedSectors = ['其他', '公告'];
const sectors = Object.entries(ztData.sector_data)
.filter(([name]) => !excludedSectors.includes(name)) // 过滤掉"其他"和"公告"
.map(([name, data]) => ({
name,
count: data.count || 0,
stock_codes: data.stock_codes || [],
related_events: data.related_events || [],
// 计算热度分数:涨停数 * 权重 + 关联事件数 * 权重
hotScore: (data.count || 0) * 10 + (data.related_events?.length || 0) * 5,
}))
.sort((a, b) => b.hotScore - a.hotScore)
.slice(0, limit);
return sectors;
},
/**
* 获取连板龙头股
* @param {object} ztData - 涨停原始数据
* @param {number} minDays - 最小连板天数
* @returns {Array} 连板股列表
*/
getContinuousLeaders: (ztData, minDays = 2) => {
const stocks = ztData?.stocks || ztData?.stock_infos || [];
// 解析连板天数
const parseDay = (str) => {
if (!str || str === '首板') return 1;
const match = str.match(/(\d+)/);
return match ? parseInt(match[1]) : 1;
};
return stocks
.filter(s => parseDay(s.continuous_days) >= minDays)
.sort((a, b) => parseDay(b.continuous_days) - parseDay(a.continuous_days));
},
/**
* 获取热门关键词
* @param {object} ztData - 涨停原始数据
* @param {number} limit - 返回数量
* @returns {Array} 关键词列表
*/
getHotKeywords: (ztData, limit = 12) => {
return (ztData?.word_freq_data || []).slice(0, limit);
},
/**
* 获取日历综合数据(涨停数 + 事件数 + 涨跌幅 + 热门概念)
* @param {number} year - 年份
* @param {number} month - 月份 (1-12)
* @returns {Promise<object>} 日历数据
*/
getCalendarData: async (year, month) => {
try {
// 获取日期列表
const datesResult = await ztService.getAvailableDates();
if (!datesResult.success) {
return { success: false, data: [] };
}
const dates = datesResult.data.dates || [];
const calendarData = [];
// 过滤当月数据
const monthStr = String(month).padStart(2, '0');
const monthPrefix = `${year}${monthStr}`;
for (const dateInfo of dates) {
const dateStr = dateInfo.date;
if (!dateStr.startsWith(monthPrefix)) continue;
// 获取每日详细数据
const dailyResult = await ztService.getDailyZt(dateStr);
let topSector = '';
let indexChange = null;
if (dailyResult.success && dailyResult.data) {
// 获取最热板块
const hotSectors = ztService.getHotSectors(dailyResult.data, 1);
topSector = hotSectors[0]?.name || '';
// 获取指数涨跌幅(如果数据中有)
indexChange = dailyResult.data.index_change || null;
}
calendarData.push({
date: dateStr,
ztCount: dateInfo.count || 0,
topSector,
eventCount: dateInfo.event_count || 0,
indexChange,
});
}
return { success: true, data: calendarData };
} catch (error) {
console.error('获取日历数据失败:', error);
return { success: false, data: [] };
}
},
/**
* 快速获取日历数据(只从 dates.json 获取基础信息)
* @param {number} year - 年份
* @param {number} month - 月份 (1-12)
* @returns {Promise<object>} 日历数据
*/
getCalendarDataFast: async (year, month) => {
try {
const datesResult = await ztService.getAvailableDates();
if (!datesResult.success) {
return { success: false, data: [] };
}
const dates = datesResult.data.dates || [];
const monthStr = String(month).padStart(2, '0');
const monthPrefix = `${year}${monthStr}`;
const calendarData = dates
.filter(d => d.date.startsWith(monthPrefix))
.map(d => ({
date: d.date,
ztCount: d.count || 0,
formattedDate: d.formatted_date,
topSector: d.top_sector || '',
eventCount: d.event_count || 0,
indexChange: d.index_change || null,
}));
return { success: true, data: calendarData };
} catch (error) {
console.error('获取日历数据失败:', error);
return { success: false, data: [] };
}
},
};
export default ztService;

View File

@@ -0,0 +1,18 @@
/**
* Redux Store 配置
*/
import { configureStore } from '@reduxjs/toolkit';
import eventsReducer from './slices/eventsSlice';
const store = configureStore({
reducer: {
events: eventsReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export default store;

View File

@@ -0,0 +1,326 @@
/**
* 事件状态管理 Slice
* 使用 Redux Toolkit 管理事件列表和详情状态
*/
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import eventService from '../../services/eventService';
// 初始状态
const initialState = {
// 事件列表
events: [],
// 分页信息
pagination: {
page: 1,
pages: 1,
per_page: 10,
total: 0,
has_next: false,
has_prev: false,
},
// 筛选条件
filters: {
sort: 'new',
importance: '',
q: '',
recent_days: '',
start_date: '',
end_date: '',
},
// 热点事件
hotEvents: [],
// 热门关键词
popularKeywords: [],
// 主线/题材事件数据
mainlineData: [],
mainlineGroupBy: 'lv1',
// 当前事件详情
currentEvent: null,
// 加载状态
loading: {
events: false,
hotEvents: false,
keywords: false,
detail: false,
mainline: false,
},
// 错误信息
error: null,
// 刷新状态(用于下拉刷新)
refreshing: false,
};
/**
* 获取事件列表
*/
export const fetchEvents = createAsyncThunk(
'events/fetchEvents',
async ({ page = 1, refresh = false }, { getState, rejectWithValue }) => {
try {
const { filters } = getState().events;
const response = await eventService.getEvents({
page,
per_page: 10,
...filters,
});
if (response.success) {
return {
events: response.data.events,
pagination: response.data.pagination,
refresh,
};
} else {
return rejectWithValue(response.message || '获取事件列表失败');
}
} catch (error) {
return rejectWithValue(error.message);
}
}
);
/**
* 获取热点事件
*/
export const fetchHotEvents = createAsyncThunk(
'events/fetchHotEvents',
async (_, { rejectWithValue }) => {
try {
const response = await eventService.getHotEvents({ limit: 5 });
if (response.success) {
return response.data;
} else {
return rejectWithValue(response.message || '获取热点事件失败');
}
} catch (error) {
return rejectWithValue(error.message);
}
}
);
/**
* 获取热门关键词
*/
export const fetchPopularKeywords = createAsyncThunk(
'events/fetchPopularKeywords',
async (_, { rejectWithValue }) => {
try {
const response = await eventService.getPopularKeywords(20);
if (response.success) {
return response.data;
} else {
return rejectWithValue(response.message || '获取热门关键词失败');
}
} catch (error) {
return rejectWithValue(error.message);
}
}
);
/**
* 获取事件详情
*/
export const fetchEventDetail = createAsyncThunk(
'events/fetchEventDetail',
async (eventId, { rejectWithValue }) => {
try {
const response = await eventService.getEventDetail(eventId);
if (response.success) {
return response.data;
} else {
return rejectWithValue(response.message || '获取事件详情失败');
}
} catch (error) {
return rejectWithValue(error.message);
}
}
);
/**
* 获取主线/题材事件数据
* @param {object} params
* @param {string} params.groupBy - 分组级别 (lv1/lv2/lv3)
* @param {number} params.recentDays - 最近天数(可选,与时间范围二选一)
* @param {string} params.startDate - 开始时间(可选)
* @param {string} params.endDate - 结束时间(可选)
*/
export const fetchMainlineEvents = createAsyncThunk(
'events/fetchMainlineEvents',
async ({ groupBy = 'lv1', recentDays, startDate, endDate } = {}, { rejectWithValue }) => {
try {
const params = { group_by: groupBy };
// 优先使用时间范围,否则使用 recentDays
if (startDate && endDate) {
params.start_date = startDate;
params.end_date = endDate;
} else if (recentDays) {
params.recent_days = recentDays;
}
const response = await eventService.getMainlineEvents(params);
if (response.success) {
return { data: response.data, groupBy };
} else {
return rejectWithValue(response.message || '获取主线事件失败');
}
} catch (error) {
return rejectWithValue(error.message);
}
}
);
/**
* 切换事件关注状态
*/
export const toggleEventFollow = createAsyncThunk(
'events/toggleFollow',
async (eventId, { getState, rejectWithValue }) => {
try {
const { events, currentEvent } = getState().events;
// 查找当前事件的关注状态
const event = events.find((e) => e.id === eventId) || currentEvent;
const isFollowing = event?.is_following || false;
const response = await eventService.toggleFollow(eventId, !isFollowing);
if (response.success) {
return { eventId, isFollowing: !isFollowing };
} else {
return rejectWithValue(response.message || '操作失败');
}
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const eventsSlice = createSlice({
name: 'events',
initialState,
reducers: {
// 设置筛选条件
setFilters: (state, action) => {
state.filters = { ...state.filters, ...action.payload };
},
// 重置筛选条件
resetFilters: (state) => {
state.filters = initialState.filters;
},
// 清除错误
clearError: (state) => {
state.error = null;
},
// 清除当前事件详情
clearCurrentEvent: (state) => {
state.currentEvent = null;
},
// 设置主线分组级别
setMainlineGroupBy: (state, action) => {
state.mainlineGroupBy = action.payload;
},
},
extraReducers: (builder) => {
builder
// 获取事件列表
.addCase(fetchEvents.pending, (state, action) => {
if (action.meta.arg.refresh) {
state.refreshing = true;
} else {
state.loading.events = true;
}
})
.addCase(fetchEvents.fulfilled, (state, action) => {
const { events, pagination, refresh } = action.payload;
if (refresh || pagination.page === 1) {
state.events = events;
} else {
// 加载更多时追加
state.events = [...state.events, ...events];
}
state.pagination = pagination;
state.loading.events = false;
state.refreshing = false;
state.error = null;
})
.addCase(fetchEvents.rejected, (state, action) => {
state.loading.events = false;
state.refreshing = false;
state.error = action.payload;
})
// 获取热点事件
.addCase(fetchHotEvents.pending, (state) => {
state.loading.hotEvents = true;
})
.addCase(fetchHotEvents.fulfilled, (state, action) => {
state.hotEvents = action.payload;
state.loading.hotEvents = false;
})
.addCase(fetchHotEvents.rejected, (state, action) => {
state.loading.hotEvents = false;
state.error = action.payload;
})
// 获取热门关键词
.addCase(fetchPopularKeywords.pending, (state) => {
state.loading.keywords = true;
})
.addCase(fetchPopularKeywords.fulfilled, (state, action) => {
state.popularKeywords = action.payload;
state.loading.keywords = false;
})
.addCase(fetchPopularKeywords.rejected, (state, action) => {
state.loading.keywords = false;
state.error = action.payload;
})
// 获取事件详情
.addCase(fetchEventDetail.pending, (state) => {
state.loading.detail = true;
})
.addCase(fetchEventDetail.fulfilled, (state, action) => {
state.currentEvent = action.payload;
state.loading.detail = false;
})
.addCase(fetchEventDetail.rejected, (state, action) => {
state.loading.detail = false;
state.error = action.payload;
})
// 切换关注状态
.addCase(toggleEventFollow.fulfilled, (state, action) => {
const { eventId, isFollowing } = action.payload;
// 更新列表中的事件
const eventIndex = state.events.findIndex((e) => e.id === eventId);
if (eventIndex !== -1) {
state.events[eventIndex].is_following = isFollowing;
state.events[eventIndex].follower_count += isFollowing ? 1 : -1;
}
// 更新当前事件详情
if (state.currentEvent?.id === eventId) {
state.currentEvent.is_following = isFollowing;
state.currentEvent.follower_count += isFollowing ? 1 : -1;
}
})
// 获取主线事件
.addCase(fetchMainlineEvents.pending, (state) => {
state.loading.mainline = true;
})
.addCase(fetchMainlineEvents.fulfilled, (state, action) => {
state.mainlineData = action.payload.data;
state.mainlineGroupBy = action.payload.groupBy;
state.loading.mainline = false;
})
.addCase(fetchMainlineEvents.rejected, (state, action) => {
state.loading.mainline = false;
state.error = action.payload;
});
},
});
export const { setFilters, resetFilters, clearError, clearCurrentEvent, setMainlineGroupBy } =
eventsSlice.actions;
export default eventsSlice.reducer;

View File

@@ -0,0 +1,153 @@
/**
* HeroUI 风格主题配置
* 现代、渐变、玻璃态设计
*/
import { extendTheme } from 'native-base';
// HeroUI 风格颜色系统
const colors = {
// 主色 - 蓝紫渐变
primary: {
50: '#EEF2FF',
100: '#E0E7FF',
200: '#C7D2FE',
300: '#A5B4FC',
400: '#818CF8',
500: '#7C3AED', // 主色 - 紫色
600: '#6D28D9',
700: '#5B21B6',
800: '#4C1D95',
900: '#2E1065',
},
// 次色 - 青色
secondary: {
50: '#ECFEFF',
100: '#CFFAFE',
200: '#A5F3FC',
300: '#67E8F9',
400: '#22D3EE',
500: '#06B6D4',
600: '#0891B2',
700: '#0E7490',
800: '#155E75',
900: '#164E63',
},
// 成功 - 翠绿
success: {
50: '#ECFDF5',
100: '#D1FAE5',
200: '#A7F3D0',
300: '#6EE7B7',
400: '#34D399',
500: '#10B981',
600: '#059669',
700: '#047857',
800: '#065F46',
900: '#064E3B',
},
// 危险 - 玫红
danger: {
50: '#FFF1F2',
100: '#FFE4E6',
200: '#FECDD3',
300: '#FDA4AF',
400: '#FB7185',
500: '#F43F5E',
600: '#E11D48',
700: '#BE123C',
800: '#9F1239',
900: '#881337',
},
// 警告 - 琥珀
warning: {
50: '#FFFBEB',
100: '#FEF3C7',
200: '#FDE68A',
300: '#FCD34D',
400: '#FBBF24',
500: '#F59E0B',
600: '#D97706',
700: '#B45309',
800: '#92400E',
900: '#78350F',
},
// 深色背景
dark: {
50: '#F9FAFB',
100: '#1E293B',
200: '#1A2332',
300: '#151D2B',
400: '#111827',
500: '#0F172A', // 主背景色
600: '#0D1424',
700: '#0A101C',
800: '#070B14',
900: '#05080E',
},
// 玻璃态
glass: {
light: 'rgba(255, 255, 255, 0.1)',
medium: 'rgba(255, 255, 255, 0.15)',
heavy: 'rgba(255, 255, 255, 0.2)',
border: 'rgba(255, 255, 255, 0.1)',
},
};
// 渐变色定义
export const gradients = {
primary: ['#7C3AED', '#EC4899'], // 紫→粉
secondary: ['#06B6D4', '#3B82F6'], // 青→蓝
success: ['#10B981', '#06B6D4'], // 绿→青
danger: ['#F43F5E', '#FB7185'], // 红→粉
warning: ['#F59E0B', '#FBBF24'], // 橙→黄
dark: ['#1E293B', '#0F172A'], // 深灰→深蓝
purple: ['#8B5CF6', '#EC4899'], // 紫→粉
blue: ['#3B82F6', '#06B6D4'], // 蓝→青
sunset: ['#F43F5E', '#F59E0B'], // 红→橙
};
// 重要性等级渐变
export const importanceGradients = {
S: ['#F43F5E', '#EC4899'], // 红粉渐变
A: ['#F59E0B', '#FBBF24'], // 橙黄渐变
B: ['#7C3AED', '#8B5CF6'], // 紫色渐变
C: ['#64748B', '#94A3B8'], // 灰色渐变
};
// 组件样式
const components = {
Button: {
baseStyle: {
rounded: '2xl',
},
defaultProps: {
colorScheme: 'primary',
},
},
Input: {
baseStyle: {
rounded: '2xl',
},
defaultProps: {
size: 'lg',
variant: 'filled',
},
},
Badge: {
baseStyle: {
rounded: 'full',
},
},
};
// 创建主题
const theme = extendTheme({
colors,
components,
config: {
initialColorMode: 'dark', // HeroUI 风格默认深色
},
});
export default theme;

View File

@@ -0,0 +1,74 @@
/**
* 事件相关的类型定义JSDoc 注释方式)
* 用于 IDE 类型提示
*/
/**
* @typedef {Object} Event
* @property {number} id - 事件ID
* @property {string} title - 事件标题
* @property {string} description - 事件描述
* @property {string} importance - 重要性等级 (S/A/B/C)
* @property {string} event_type - 事件类型
* @property {string} status - 事件状态
* @property {string} start_time - 开始时间
* @property {string} end_time - 结束时间
* @property {string} created_at - 创建时间
* @property {string} updated_at - 更新时间
* @property {number} hot_score - 热度分数
* @property {number} invest_score - 投资分数
* @property {number} trending_score - 趋势分数
* @property {number} view_count - 浏览量
* @property {number} post_count - 帖子数
* @property {number} follower_count - 关注人数
* @property {number} bearish_count - 看空人数
* @property {number} bullish_count - 看多人数
* @property {number} related_avg_chg - 相关股票平均涨幅
* @property {number} related_max_chg - 相关股票最大涨幅
* @property {number} related_week_chg - 相关股票周涨幅
* @property {string[]} keywords - 关键词列表
* @property {string[]} related_industries - 相关行业
* @property {boolean} is_following - 当前用户是否关注
*/
/**
* @typedef {Object} Pagination
* @property {number} page - 当前页
* @property {number} pages - 总页数
* @property {number} per_page - 每页数量
* @property {number} total - 总数量
* @property {boolean} has_next - 是否有下一页
* @property {boolean} has_prev - 是否有上一页
*/
/**
* @typedef {Object} EventListResponse
* @property {boolean} success - 是否成功
* @property {Object} data - 数据
* @property {Event[]} data.events - 事件列表
* @property {Pagination} data.pagination - 分页信息
*/
/**
* 重要性等级配置
*/
export const IMPORTANCE_LEVELS = {
S: { label: '重大', color: '#F5365C', bgColor: '#FFEEF1' },
A: { label: '重要', color: '#FB6340', bgColor: '#FFF3EE' },
B: { label: '一般', color: '#5E72E4', bgColor: '#EEF1FF' },
C: { label: '普通', color: '#8898AA', bgColor: '#F7F8FA' },
};
/**
* 排序选项
*/
export const SORT_OPTIONS = [
{ value: 'new', label: '最新' },
{ value: 'hot', label: '最热' },
{ value: 'importance', label: '重要性' },
];
export default {
IMPORTANCE_LEVELS,
SORT_OPTIONS,
};

View File

@@ -0,0 +1,187 @@
/**
* 中国股市交易日工具类
* 包含节假日判断和交易日计算
*/
class TradingDayUtils {
constructor() {
// 2024-2026年的法定节假日需要定期更新
this.holidays = new Set([
// 2024年节假日
'2024-01-01', // 元旦
'2024-02-09', '2024-02-10', '2024-02-11', '2024-02-12', '2024-02-13',
'2024-02-14', '2024-02-15', '2024-02-16', '2024-02-17', // 春节
'2024-04-04', '2024-04-05', '2024-04-06', // 清明节
'2024-05-01', '2024-05-02', '2024-05-03', '2024-05-04', '2024-05-05', // 劳动节
'2024-06-08', '2024-06-09', '2024-06-10', // 端午节
'2024-09-15', '2024-09-16', '2024-09-17', // 中秋节
'2024-10-01', '2024-10-02', '2024-10-03', '2024-10-04',
'2024-10-05', '2024-10-06', '2024-10-07', // 国庆节
// 2025年节假日
'2025-01-01', // 元旦
'2025-01-28', '2025-01-29', '2025-01-30', '2025-01-31',
'2025-02-01', '2025-02-02', '2025-02-03', '2025-02-04', // 春节
'2025-04-04', '2025-04-05', '2025-04-06', // 清明节
'2025-05-01', '2025-05-02', '2025-05-03', // 劳动节
'2025-05-31', '2025-06-01', '2025-06-02', // 端午节
'2025-10-01', '2025-10-02', '2025-10-03', '2025-10-04',
'2025-10-05', '2025-10-06', '2025-10-07', '2025-10-08', // 国庆节+中秋节
// 2026年节假日预估
'2026-01-01', '2026-01-02', // 元旦
'2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20',
'2026-02-21', '2026-02-22', // 春节
'2026-04-05', '2026-04-06', '2026-04-07', // 清明节
'2026-05-01', '2026-05-02', '2026-05-03', // 劳动节
'2026-06-19', '2026-06-20', '2026-06-21', // 端午节
'2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
'2026-10-05', '2026-10-06', '2026-10-07', // 国庆节
]);
// A股交易时间
this.marketOpenTime = { hour: 9, minute: 30 };
this.marketCloseTime = { hour: 15, minute: 0 };
}
/**
* 判断是否为周末
* @param {Date} date
* @returns {boolean}
*/
isWeekend(date) {
const day = date.getDay();
return day === 0 || day === 6; // 0是周日6是周六
}
/**
* 判断是否为节假日
* @param {Date} date
* @returns {boolean}
*/
isHoliday(date) {
const dateStr = this.formatDate(date);
return this.holidays.has(dateStr);
}
/**
* 判断是否为交易日
* @param {Date} date
* @returns {boolean}
*/
isTradingDay(date) {
return !this.isWeekend(date) && !this.isHoliday(date);
}
/**
* 格式化日期为 YYYY-MM-DD
* @param {Date} date
* @returns {string}
*/
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 格式化日期时间为 YYYY-MM-DD HH:mm:ss
* @param {Date} date
* @returns {string}
*/
formatDateTime(date) {
const dateStr = this.formatDate(date);
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
const second = String(date.getSeconds()).padStart(2, '0');
return `${dateStr} ${hour}:${minute}:${second}`;
}
/**
* 判断时间是否在交易时间后15:00后
* @param {Date} datetime
* @returns {boolean}
*/
isAfterTradingHours(datetime) {
const hours = datetime.getHours();
const minutes = datetime.getMinutes();
return hours > 15 || (hours === 15 && minutes > 0);
}
/**
* 获取下一个交易日
* @param {Date} date
* @returns {Date}
*/
getNextTradingDay(date) {
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
while (!this.isTradingDay(nextDay)) {
nextDay.setDate(nextDay.getDate() + 1);
}
return nextDay;
}
/**
* 获取上一个交易日
* @param {Date} date
* @returns {Date}
*/
getPreviousTradingDay(date) {
const prevDay = new Date(date);
prevDay.setDate(prevDay.getDate() - 1);
while (!this.isTradingDay(prevDay)) {
prevDay.setDate(prevDay.getDate() - 1);
}
return prevDay;
}
/**
* 获取当前交易日的时间范围
* 规则上一个交易日的15:00 到 现在
* @returns {{ startDate: string, endDate: string }}
*/
getCurrentTradingDayRange() {
const now = new Date();
const prevTradingDay = this.getPreviousTradingDay(now);
// 设置为上一个交易日的15:00
prevTradingDay.setHours(15, 0, 0, 0);
return {
startDate: this.formatDateTime(prevTradingDay),
endDate: this.formatDateTime(now),
};
}
/**
* 根据事件时间获取对应的交易日
* @param {Date|string} eventDateTime 事件时间
* @returns {string} 交易日期 YYYY-MM-DD
*/
getEffectiveTradingDay(eventDateTime) {
const datetime = typeof eventDateTime === 'string' ? new Date(eventDateTime) : eventDateTime;
// 如果是非交易日,直接返回下一个交易日
if (!this.isTradingDay(datetime)) {
return this.formatDate(this.getNextTradingDay(datetime));
}
// 如果是交易日的15:00之后返回下一个交易日
if (this.isAfterTradingHours(datetime)) {
return this.formatDate(this.getNextTradingDay(datetime));
}
// 交易日的15:00前返回当天
return this.formatDate(datetime);
}
}
// 导出单例
export const tradingDayUtils = new TradingDayUtils();
export default tradingDayUtils;