ios app
This commit is contained in:
15
argon-pro-react-native/src/components/index.js
Normal file
15
argon-pro-react-native/src/components/index.js
Normal 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';
|
||||
11
argon-pro-react-native/src/constants/index.js
Normal file
11
argon-pro-react-native/src/constants/index.js
Normal 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 };
|
||||
156
argon-pro-react-native/src/contexts/AuthContext.js
Normal file
156
argon-pro-react-native/src/contexts/AuthContext.js
Normal 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;
|
||||
398
argon-pro-react-native/src/screens/Auth/LoginScreen.js
Normal file
398
argon-pro-react-native/src/screens/Auth/LoginScreen.js
Normal 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;
|
||||
5
argon-pro-react-native/src/screens/Auth/index.js
Normal file
5
argon-pro-react-native/src/screens/Auth/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 认证模块导出
|
||||
*/
|
||||
|
||||
export { default as LoginScreen } from './LoginScreen';
|
||||
344
argon-pro-react-native/src/screens/Events/EventCard.js
Normal file
344
argon-pro-react-native/src/screens/Events/EventCard.js
Normal 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;
|
||||
340
argon-pro-react-native/src/screens/Events/EventComments.js
Normal file
340
argon-pro-react-native/src/screens/Events/EventComments.js
Normal 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);
|
||||
610
argon-pro-react-native/src/screens/Events/EventDetail.js
Normal file
610
argon-pro-react-native/src/screens/Events/EventDetail.js
Normal 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;
|
||||
660
argon-pro-react-native/src/screens/Events/EventList.js
Normal file
660
argon-pro-react-native/src/screens/Events/EventList.js
Normal 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;
|
||||
308
argon-pro-react-native/src/screens/Events/FilterModal.js
Normal file
308
argon-pro-react-native/src/screens/Events/FilterModal.js
Normal 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;
|
||||
286
argon-pro-react-native/src/screens/Events/HistoricalEvents.js
Normal file
286
argon-pro-react-native/src/screens/Events/HistoricalEvents.js
Normal 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);
|
||||
701
argon-pro-react-native/src/screens/Events/MainlineView.js
Normal file
701
argon-pro-react-native/src/screens/Events/MainlineView.js
Normal 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;
|
||||
253
argon-pro-react-native/src/screens/Events/RelatedConcepts.js
Normal file
253
argon-pro-react-native/src/screens/Events/RelatedConcepts.js
Normal 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);
|
||||
308
argon-pro-react-native/src/screens/Events/RelatedStocks.js
Normal file
308
argon-pro-react-native/src/screens/Events/RelatedStocks.js
Normal 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);
|
||||
293
argon-pro-react-native/src/screens/Events/SankeyFlow.js
Normal file
293
argon-pro-react-native/src/screens/Events/SankeyFlow.js
Normal 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);
|
||||
294
argon-pro-react-native/src/screens/Events/StockDetailModal.js
Normal file
294
argon-pro-react-native/src/screens/Events/StockDetailModal.js
Normal 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);
|
||||
541
argon-pro-react-native/src/screens/Events/TransmissionChain.js
Normal file
541
argon-pro-react-native/src/screens/Events/TransmissionChain.js
Normal 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);
|
||||
7
argon-pro-react-native/src/screens/Events/index.js
Normal file
7
argon-pro-react-native/src/screens/Events/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Events 模块导出
|
||||
*/
|
||||
|
||||
export { default as EventList } from './EventList';
|
||||
export { default as EventDetail } from './EventDetail';
|
||||
export { default as EventCard } from './EventCard';
|
||||
657
argon-pro-react-native/src/screens/Market/EventCalendar.js
Normal file
657
argon-pro-react-native/src/screens/Market/EventCalendar.js
Normal 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}>涨停<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;
|
||||
676
argon-pro-react-native/src/screens/Market/MarketHot.js
Normal file
676
argon-pro-react-native/src/screens/Market/MarketHot.js
Normal 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;
|
||||
360
argon-pro-react-native/src/screens/Market/SectorDetail.js
Normal file
360
argon-pro-react-native/src/screens/Market/SectorDetail.js
Normal 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;
|
||||
158
argon-pro-react-native/src/screens/Market/StockDetail.js
Normal file
158
argon-pro-react-native/src/screens/Market/StockDetail.js
Normal 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;
|
||||
588
argon-pro-react-native/src/screens/Market/TodayStats.js
Normal file
588
argon-pro-react-native/src/screens/Market/TodayStats.js
Normal 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;
|
||||
9
argon-pro-react-native/src/screens/Market/index.js
Normal file
9
argon-pro-react-native/src/screens/Market/index.js
Normal 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';
|
||||
62
argon-pro-react-native/src/services/api.js
Normal file
62
argon-pro-react-native/src/services/api.js
Normal 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;
|
||||
175
argon-pro-react-native/src/services/authService.js
Normal file
175
argon-pro-react-native/src/services/authService.js
Normal 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;
|
||||
239
argon-pro-react-native/src/services/eventService.js
Normal file
239
argon-pro-react-native/src/services/eventService.js
Normal 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;
|
||||
304
argon-pro-react-native/src/services/ztService.js
Normal file
304
argon-pro-react-native/src/services/ztService.js
Normal 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;
|
||||
18
argon-pro-react-native/src/store/index.js
Normal file
18
argon-pro-react-native/src/store/index.js
Normal 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;
|
||||
326
argon-pro-react-native/src/store/slices/eventsSlice.js
Normal file
326
argon-pro-react-native/src/store/slices/eventsSlice.js
Normal 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;
|
||||
153
argon-pro-react-native/src/theme/index.js
Normal file
153
argon-pro-react-native/src/theme/index.js
Normal 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;
|
||||
74
argon-pro-react-native/src/types/event.js
Normal file
74
argon-pro-react-native/src/types/event.js
Normal 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,
|
||||
};
|
||||
187
argon-pro-react-native/src/utils/tradingDayUtils.js
Normal file
187
argon-pro-react-native/src/utils/tradingDayUtils.js
Normal 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;
|
||||
Reference in New Issue
Block a user