This commit is contained in:
2025-10-11 12:10:00 +08:00
parent 8107dee8d3
commit 4d0dc109bc
109 changed files with 152150 additions and 8037 deletions

View File

@@ -25,26 +25,22 @@ import {
Spinner,
Divider,
Alert,
AlertIcon,
useDisclosure
AlertIcon
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { FaUser, FaEnvelope, FaMobile, FaWeixin, FaLock, FaQrcode, FaCode } from "react-icons/fa";
import { FaUser, FaEnvelope, FaMobile, FaWeixin, FaLock, FaQrcode } from "react-icons/fa";
import { useNavigate, Link, useLocation } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
import PrivacyPolicyModal from "../../../components/PrivacyPolicyModal";
import UserAgreementModal from "../../../components/UserAgreementModal";
// API配置
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : "http://49.232.185.254:5000";
const API_BASE_URL = isProduction ? "" : "http://49.232.185.254:5001";
export default function SignInIllustration() {
const [loginType, setLoginType] = useState(0); // 0: 微信, 1: 手机号
const [loginType, setLoginType] = useState(0); // 0: 微信, 1: 手机号, 2: 邮箱, 3: 用户名
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [agreeToTerms, setAgreeToTerms] = useState(false);
// 传统登录表单数据
const [formData, setFormData] = useState({
@@ -52,50 +48,16 @@ export default function SignInIllustration() {
email: "",
phone: "",
password: "",
verificationCode: "", // 添加验证码字段
});
// 验证码登录相关状态
const [useVerificationCode, setUseVerificationCode] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const [sendingCode, setSendingCode] = useState(false);
// 微信登录相关状态
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
// 隐私政策弹窗状态
const {
isOpen: isPrivacyModalOpen,
onOpen: onPrivacyModalOpen,
onClose: onPrivacyModalClose
} = useDisclosure();
// 用户协议弹窗状态
const {
isOpen: isUserAgreementModalOpen,
onOpen: onUserAgreementModalOpen,
onClose: onUserAgreementModalClose
} = useDisclosure();
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const { login, checkSession } = useAuth();
// 倒计时效果
useEffect(() => {
let timer;
if (countdown > 0) {
timer = setInterval(() => {
setCountdown(prev => prev - 1);
}, 1000);
} else if (countdown === 0) {
setVerificationCodeSent(false);
}
return () => clearInterval(timer);
}, [countdown]);
// 检查URL参数中的错误信息微信登录失败时
useEffect(() => {
const params = new URLSearchParams(location.search);
@@ -173,17 +135,6 @@ export default function SignInIllustration() {
// 打开微信登录窗口
const openWechatLogin = () => {
if (!agreeToTerms) {
toast({
title: "请先同意协议",
description: "请勾选同意用户协议和隐私政策后再使用微信登录",
status: "warning",
duration: 3000,
isClosable: true,
});
return;
}
if (wechatAuthUrl) {
// 方案1直接跳转推荐
window.location.href = wechatAuthUrl;
@@ -201,70 +152,6 @@ export default function SignInIllustration() {
}
};
// 发送验证码
const sendVerificationCode = async () => {
const credential = formData.phone;
const type = 'phone';
if (!credential) {
toast({
title: "请先输入手机号",
status: "warning",
duration: 3000,
});
return;
}
// 基本格式验证
if (!/^1[3-9]\d{9}$/.test(credential)) {
toast({
title: "请输入有效的手机号",
status: "warning",
duration: 3000,
});
return;
}
try {
setSendingCode(true);
const response = await fetch(`${API_BASE_URL}/api/auth/send-verification-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential,
type,
purpose: 'login'
}),
});
const data = await response.json();
if (response.ok && data.success) {
toast({
title: "验证码已发送",
description: "验证码已发送到您的手机号",
status: "success",
duration: 3000,
});
setVerificationCodeSent(true);
setCountdown(60); // 60秒倒计时
} else {
throw new Error(data.error || '发送验证码失败');
}
} catch (error) {
toast({
title: "发送验证码失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
});
} finally {
setSendingCode(false);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
@@ -275,18 +162,6 @@ export default function SignInIllustration() {
const handleTraditionalLogin = async (e) => {
e.preventDefault();
if (!agreeToTerms) {
toast({
title: "请先同意协议",
description: "请勾选同意用户协议和隐私政策后再登录",
status: "warning",
duration: 3000,
isClosable: true,
});
return;
}
setIsLoading(true);
try {
@@ -296,40 +171,28 @@ export default function SignInIllustration() {
if (loginType === 1) {
credential = formData.phone;
authLoginType = 'phone';
} else if (loginType === 2) {
credential = formData.email;
authLoginType = 'email';
} else if (loginType === 3) {
credential = formData.username;
authLoginType = 'username';
}
// 验证码登录
if (useVerificationCode && loginType === 1) {
if (!credential || !formData.verificationCode) {
toast({
title: "请填写完整信息",
description: "手机号和验证码不能为空",
status: "warning",
duration: 3000,
});
return;
}
if (!credential || !formData.password) {
toast({
title: "请填写完整信息",
description: "用户名和密码不能为空",
status: "warning",
duration: 3000,
});
return;
}
const result = await loginWithVerificationCode(credential, formData.verificationCode, authLoginType);
if (result.success) {
navigate("/home");
}
} else {
// 传统密码登录
if (!credential || !formData.password) {
toast({
title: "请填写完整信息",
description: `${getCredentialName()}和密码不能为空`,
status: "warning",
duration: 3000,
});
return;
}
const result = await login(credential, formData.password, authLoginType);
const result = await login(credential, formData.password, authLoginType);
if (result.success) {
navigate("/home");
}
if (result.success) {
navigate("/home");
}
} catch (error) {
console.error('Login error:', error);
@@ -338,63 +201,31 @@ export default function SignInIllustration() {
}
};
// 验证码登录函数
const loginWithVerificationCode = async (credential, verificationCode, authLoginType) => {
try {
const response = await fetch(`${API_BASE_URL}/api/auth/login-with-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
credential,
verification_code: verificationCode,
login_type: authLoginType
}),
});
const data = await response.json();
if (response.ok && data.success) {
// 更新认证状态
await checkSession();
toast({
title: "登录成功",
description: "欢迎回来!",
status: "success",
duration: 3000,
});
return { success: true };
} else {
throw new Error(data.error || '验证码登录失败');
}
} catch (error) {
toast({
title: "登录失败",
description: error.message || "请检查验证码是否正确",
status: "error",
duration: 3000,
});
return { success: false, error: error.message };
const getInputPlaceholder = () => {
switch (loginType) {
case 1: return "请输入手机号";
case 2: return "请输入邮箱";
case 3: return "请输入用户名";
default: return "请输入用户名";
}
};
// 获取凭据名称
const getCredentialName = () => {
return "手机号";
};
const getInputPlaceholder = () => {
return "请输入手机号";
};
const getInputName = () => {
return "phone";
switch (loginType) {
case 1: return "phone";
case 2: return "email";
case 3: return "username";
default: return "username";
}
};
const getInputValue = () => {
return formData.phone;
switch (loginType) {
case 1: return formData.phone;
case 2: return formData.email;
case 3: return formData.username;
default: return formData.username;
}
};
return (
@@ -532,6 +363,32 @@ export default function SignInIllustration() {
<Icon as={FaMobile} mr={1} />
手机号
</Tab>
<Tab
borderRadius="lg"
_selected={{
bg: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
transform: "scale(1.02)"
}}
transition="all 0.2s"
fontSize="sm"
>
<Icon as={FaEnvelope} mr={1} />
邮箱
</Tab>
<Tab
borderRadius="lg"
_selected={{
bg: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
transform: "scale(1.02)"
}}
transition="all 0.2s"
fontSize="sm"
>
<Icon as={FaUser} mr={1} />
用户名
</Tab>
</TabList>
</Tabs>
</Box>
@@ -541,54 +398,16 @@ export default function SignInIllustration() {
{loginType === 0 ? (
// 微信登录 - 简化版
<VStack spacing={6}>
{/* 协议同意勾选框 - 微信登录 */}
<Box width="100%">
<Checkbox
isChecked={agreeToTerms}
onChange={(e) => setAgreeToTerms(e.target.checked)}
colorScheme="green"
size="sm"
>
<Text fontSize="sm" color="gray.600">
我已阅读并同意{" "}
<ChakraLink
color="green.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "green.600" }}
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="green.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "green.600" }}
>
隐私政策
</ChakraLink>
</Text>
</Checkbox>
{!agreeToTerms && (
<Text fontSize="xs" color="red.500" mt={1} ml={6}>
请先同意用户协议和隐私政策
</Text>
)}
</Box>
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
<VStack spacing={6}>
<Icon as={FaQrcode} w={20} h={20} color={agreeToTerms ? "green.500" : "gray.400"} />
<Icon as={FaQrcode} w={20} h={20} color="green.500" />
<VStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color={agreeToTerms ? "gray.700" : "gray.400"}>
<Text fontSize="lg" fontWeight="bold" color="gray.700">
微信扫码登录
</Text>
<Text fontSize="sm" color={agreeToTerms ? "gray.500" : "gray.400"} textAlign="center">
{agreeToTerms ? "使用微信扫一扫安全快速登录" : "请先同意协议后使用微信登录"}
<Text fontSize="sm" color="gray.500" textAlign="center">
使用微信扫一扫安全快速登录
</Text>
</VStack>
@@ -599,20 +418,19 @@ export default function SignInIllustration() {
onClick={openWechatLogin}
isLoading={isLoading || !wechatAuthUrl}
loadingText="准备中..."
isDisabled={!wechatAuthUrl || !agreeToTerms}
_hover={agreeToTerms ? {
isDisabled={!wechatAuthUrl}
_hover={{
transform: "translateY(-2px)",
boxShadow: "lg"
} : {}}
_active={agreeToTerms ? {
}}
_active={{
transform: "translateY(0)"
} : {}}
opacity={agreeToTerms ? 1 : 0.6}
}}
>
立即扫码登录
</Button>
{!wechatAuthUrl && !isLoading && agreeToTerms && (
{!wechatAuthUrl && !isLoading && (
<Button
size="sm"
variant="link"
@@ -626,17 +444,15 @@ export default function SignInIllustration() {
</Center>
<Alert
status={agreeToTerms ? "info" : "warning"}
status="info"
borderRadius="lg"
bg={agreeToTerms ? "blue.50" : "orange.50"}
bg="blue.50"
border="1px solid"
borderColor={agreeToTerms ? "blue.200" : "orange.200"}
borderColor="blue.200"
>
<AlertIcon color={agreeToTerms ? "blue.500" : "orange.500"} />
<Text fontSize="sm" color={agreeToTerms ? "blue.700" : "orange.700"}>
{agreeToTerms
? "点击按钮后将跳转到微信授权页面"
: "请先同意用户协议和隐私政策"}
<AlertIcon color="blue.500" />
<Text fontSize="sm" color="blue.700">
点击按钮后将跳转到微信授权页面
</Text>
</Alert>
</VStack>
@@ -663,115 +479,40 @@ export default function SignInIllustration() {
/>
<InputRightElement pointerEvents="none">
<Icon
as={FaMobile}
as={loginType === 1 ? FaMobile : loginType === 2 ? FaEnvelope : FaUser}
color="gray.400"
/>
</InputRightElement>
</InputGroup>
</FormControl>
{/* 登录方式切换(仅手机号显示) */}
{loginType === 1 && (
<VStack spacing={2} align="stretch">
<HStack justify="center">
<Button
size="sm"
variant={useVerificationCode ? "ghost" : "solid"}
colorScheme="blue"
onClick={() => {
setUseVerificationCode(false);
setFormData(prev => ({ ...prev, verificationCode: "" }));
<FormControl isRequired>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={handleInputChange}
placeholder="请输入密码"
borderRadius="lg"
bg="gray.50"
border="1px solid"
borderColor="gray.200"
_focus={{
borderColor: "blue.500",
boxShadow: "0 0 0 1px #667eea"
}}
>
密码登录
</Button>
<Button
size="sm"
variant={useVerificationCode ? "solid" : "ghost"}
colorScheme="green"
onClick={() => setUseVerificationCode(true)}
>
验证码登录
</Button>
</HStack>
</VStack>
)}
{/* 密码输入框 */}
{!useVerificationCode ? (
<FormControl isRequired>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={handleInputChange}
placeholder="请输入密码"
borderRadius="lg"
bg="gray.50"
border="1px solid"
borderColor="gray.200"
_focus={{
borderColor: "blue.500",
boxShadow: "0 0 0 1px #667eea"
}}
/>
<InputRightElement>
<IconButton
variant="ghost"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
/>
<InputRightElement>
<IconButton
variant="ghost"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
</FormControl>
) : (
// 验证码输入框
<VStack spacing={3} align="stretch">
<HStack>
<FormControl isRequired flex={2}>
<InputGroup size="lg">
<Input
name="verificationCode"
value={formData.verificationCode}
onChange={handleInputChange}
placeholder="请输入验证码"
borderRadius="lg"
bg="gray.50"
border="1px solid"
borderColor="gray.200"
_focus={{
borderColor: "green.500",
boxShadow: "0 0 0 1px #48bb78"
}}
maxLength={6}
/>
<InputRightElement>
<Icon as={FaCode} color="gray.400" />
</InputRightElement>
</InputGroup>
</FormControl>
<Button
flex={1}
size="lg"
colorScheme="green"
variant="outline"
onClick={sendVerificationCode}
isLoading={sendingCode}
isDisabled={verificationCodeSent && countdown > 0}
borderRadius="lg"
>
{sendingCode
? "发送中..."
: verificationCodeSent && countdown > 0
? `${countdown}s`
: "发送验证码"
}
</Button>
</HStack>
</VStack>
)}
</InputRightElement>
</InputGroup>
</FormControl>
<HStack justify="space-between" width="100%">
<Checkbox
@@ -786,66 +527,23 @@ export default function SignInIllustration() {
</ChakraLink>
</HStack>
{/* 协议同意勾选框 */}
<Box width="100%">
<Checkbox
isChecked={agreeToTerms}
onChange={(e) => setAgreeToTerms(e.target.checked)}
colorScheme="blue"
size="sm"
>
<Text fontSize="sm" color="gray.600">
我已阅读并同意{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
>
隐私政策
</ChakraLink>
</Text>
</Checkbox>
{!agreeToTerms && (
<Text fontSize="xs" color="red.500" mt={1} ml={6}>
请先同意用户协议和隐私政策
</Text>
)}
</Box>
<Button
type="submit"
width="100%"
size="lg"
background={agreeToTerms
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "gray.300"
}
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
color="white"
borderRadius="lg"
_hover={agreeToTerms ? {
_hover={{
transform: "translateY(-2px)",
boxShadow: "lg"
} : {}}
_active={agreeToTerms ? {
}}
_active={{
transform: "translateY(0)"
} : {}}
}}
isLoading={isLoading}
loadingText="登录中..."
fontWeight="bold"
isDisabled={!agreeToTerms}
cursor={agreeToTerms ? "pointer" : "not-allowed"}
>
<Icon as={FaLock} mr={2} />
登录
@@ -895,38 +593,26 @@ export default function SignInIllustration() {
zIndex={1}
>
<HStack spacing={6}>
<ChakraLink
color="white"
fontSize="sm"
opacity={0.8}
_hover={{ opacity: 1 }}
onClick={onUserAgreementModalOpen}
>
用户协议
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>
Company
</ChakraLink>
<ChakraLink
color="white"
fontSize="sm"
opacity={0.8}
_hover={{ opacity: 1 }}
onClick={onPrivacyModalOpen}
>
隐私政策
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>
About Us
</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>
Team
</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>
Products
</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>
Blog
</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>
Pricing
</ChakraLink>
</HStack>
</Box>
{/* 隐私政策弹窗 */}
<PrivacyPolicyModal
isOpen={isPrivacyModalOpen}
onClose={onPrivacyModalClose}
/>
{/* 用户协议弹窗 */}
<UserAgreementModal
isOpen={isUserAgreementModalOpen}
onClose={onUserAgreementModalClose}
/>
</Flex>
);
}

View File

@@ -25,23 +25,19 @@ import {
Spinner,
FormLabel,
FormErrorMessage,
Divider,
useDisclosure,
Checkbox
Divider
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { FaWeixin, FaMobile, FaEnvelope, FaUser, FaLock } from "react-icons/fa";
import { useNavigate, Link } from "react-router-dom";
import axios from "axios";
import { useAuth } from '../../../contexts/AuthContext'; // 假设AuthContext在这个路径
import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
import UserAgreementModal from '../../../components/UserAgreementModal';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
export default function SignUpPage() {
const [registerType, setRegisterType] = useState(0); // 0: 微信, 1: 手机号
const [registerType, setRegisterType] = useState(0); // 0: 微信, 1: 手机号, 2: 邮箱
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
@@ -50,7 +46,6 @@ export default function SignUpPage() {
const [wechatStatus, setWechatStatus] = useState("waiting");
const [errors, setErrors] = useState({});
const [checkInterval, setCheckInterval] = useState(null);
const [agreeToTerms, setAgreeToTerms] = useState(false);
const [formData, setFormData] = useState({
username: "",
@@ -61,20 +56,6 @@ export default function SignUpPage() {
verificationCode: ""
});
// 隐私政策弹窗状态
const {
isOpen: isPrivacyModalOpen,
onOpen: onPrivacyModalOpen,
onClose: onPrivacyModalClose
} = useDisclosure();
// 用户协议弹窗状态
const {
isOpen: isUserAgreementModalOpen,
onOpen: onUserAgreementModalOpen,
onClose: onUserAgreementModalClose
} = useDisclosure();
const navigate = useNavigate();
const toast = useToast();
const { loginWithWechat } = useAuth(); // 使用认证上下文
@@ -335,27 +316,28 @@ export default function SignUpPage() {
// 初始化时如果选择了微信登录获取授权URL
useEffect(() => {
if (registerType === 0 && agreeToTerms) {
if (registerType === 0) {
getWechatQRCode();
}
}, [registerType, agreeToTerms]);
}, []);
// 发送验证码
const sendVerificationCode = async () => {
const contact = formData.phone;
const endpoint = "send-sms-code";
const fieldName = "phone";
const isPhone = registerType === 1;
const contact = isPhone ? formData.phone : formData.email;
const endpoint = isPhone ? "send-sms-code" : "send-email-code";
const fieldName = isPhone ? "phone" : "email";
if (!contact) {
toast({
title: "请输入手机号",
title: `请输入${isPhone ? "手机号" : "邮箱"}`,
status: "warning",
duration: 2000,
});
return;
}
if (!/^1[3-9]\d{9}$/.test(contact)) {
if (isPhone && !/^1[3-9]\d{9}$/.test(contact)) {
toast({
title: "请输入正确的手机号",
status: "warning",
@@ -364,6 +346,15 @@ export default function SignUpPage() {
return;
}
if (!isPhone && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact)) {
toast({
title: "请输入正确的邮箱格式",
status: "warning",
duration: 2000,
});
return;
}
try {
setIsLoading(true);
await axios.post(`${API_BASE_URL}/api/auth/${endpoint}`, {
@@ -372,7 +363,7 @@ export default function SignUpPage() {
toast({
title: "验证码已发送",
description: "请查收短信",
description: `请查收${isPhone ? "短信" : "邮件"}`,
status: "success",
duration: 3000,
});
@@ -424,12 +415,20 @@ export default function SignUpPage() {
} else if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
newErrors.phone = "请输入正确的手机号";
}
}
if (!formData.verificationCode) {
newErrors.verificationCode = "请输入验证码";
if (registerType === 2) {
if (!formData.email) {
newErrors.email = "请输入邮箱";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "请输入正确的邮箱格式";
}
}
if ((registerType === 1 || registerType === 2) && !formData.verificationCode) {
newErrors.verificationCode = "请输入验证码";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -438,17 +437,6 @@ export default function SignUpPage() {
const handleSubmit = async (e) => {
e.preventDefault();
if (!agreeToTerms) {
toast({
title: "请先同意协议",
description: "请勾选同意用户协议和隐私政策后再注册",
status: "warning",
duration: 3000,
isClosable: true,
});
return;
}
if (!validateForm()) {
return;
}
@@ -456,13 +444,25 @@ export default function SignUpPage() {
setIsLoading(true);
try {
const endpoint = "/api/auth/register/phone";
const data = {
phone: formData.phone,
code: formData.verificationCode,
username: formData.username,
password: formData.password
};
let endpoint, data;
if (registerType === 1) {
endpoint = "/api/auth/register/phone";
data = {
phone: formData.phone,
code: formData.verificationCode,
username: formData.username,
password: formData.password
};
} else {
endpoint = "/api/auth/register/email";
data = {
email: formData.email,
code: formData.verificationCode,
username: formData.username,
password: formData.password
};
}
await axios.post(`${API_BASE_URL}${endpoint}`, data);
@@ -505,7 +505,6 @@ export default function SignUpPage() {
setRegisterType(newType);
setErrors({});
setAgreeToTerms(false); // 切换注册方式时重置协议同意状态
setFormData({
username: "",
email: "",
@@ -519,7 +518,7 @@ export default function SignUpPage() {
setWechatStatus("waiting");
setWechatAuthUrl("");
setWechatSessionId("");
// 不自动获取二维码,等用户同意协议后再获取
getWechatQRCode();
}
};
@@ -628,6 +627,9 @@ export default function SignUpPage() {
<Tab borderRadius="lg" _selected={{ bg: "linear-gradient(135deg, #ff8c00 0%, #ff6347 100%)", color: "white", transform: "scale(1.02)" }} transition="all 0.2s">
<Icon as={FaMobile} mr={2} /><Text fontSize="sm">手机号</Text>
</Tab>
<Tab borderRadius="lg" _selected={{ bg: "linear-gradient(135deg, #ff8c00 0%, #ff6347 100%)", color: "white", transform: "scale(1.02)" }} transition="all 0.2s">
<Icon as={FaEnvelope} mr={2} /><Text fontSize="sm">邮箱</Text>
</Tab>
</TabList>
</Tabs>
</Box>
@@ -638,112 +640,49 @@ export default function SignUpPage() {
{/* 微信注册 */}
{registerType === 0 && (
<>
{/* 协议同意勾选框 - 微信注册 */}
<Box width="100%" mb={4}>
<Checkbox
isChecked={agreeToTerms}
onChange={(e) => setAgreeToTerms(e.target.checked)}
colorScheme="orange"
size="sm"
>
<Text fontSize="sm" color="gray.600">
我已阅读并同意{" "}
<ChakraLink
color="orange.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "orange.600" }}
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="orange.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "orange.600" }}
>
隐私政策
</ChakraLink>
</Text>
</Checkbox>
{!agreeToTerms && (
<Text fontSize="xs" color="red.500" mt={1} ml={6}>
请先同意用户协议和隐私政策
</Text>
)}
</Box>
<Center width="100%" height="420px" bg="gray.50" borderRadius="lg" p={4} mb={4}>
{agreeToTerms ? (
wechatAuthUrl && wechatStatus !== "expired" ? (
<Box position="relative" width="100%" height="100%">
<iframe
src={wechatAuthUrl}
width="100%"
height="100%"
frameBorder="0"
scrolling="no"
style={{ borderRadius: '8px' }}
/>
{(wechatStatus === "login_success" || wechatStatus === "register_success") && (
<Box position="absolute" top={0} left={0} right={0} bottom={0}
bg="rgba(0,0,0,0.7)" display="flex" alignItems="center"
justifyContent="center" borderRadius="lg">
<VStack>
<Spinner color="white" />
<Text color="white" fontWeight="bold">
{wechatStatus === "login_success" ? "正在登录..." : "正在创建账号..."}
</Text>
</VStack>
</Box>
)}
</Box>
) : wechatStatus === "expired" ? (
<VStack>
<Text color="gray.500" fontWeight="bold" mb={4}>授权已过期</Text>
<Button colorScheme="orange" size="sm" onClick={getWechatQRCode}
isLoading={isLoading}>重新获取</Button>
</VStack>
) : (
<VStack>
<Spinner size="xl" color="orange.500" />
<Text color="gray.500" fontSize="sm">加载中...</Text>
</VStack>
)
) : (
<VStack spacing={4}>
<Icon as={FaWeixin} w={20} h={20} color="gray.400" />
<VStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color="gray.400">
微信扫码注册
</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
请先同意用户协议和隐私政策
</Text>
{wechatAuthUrl && wechatStatus !== "expired" ? (
<Box position="relative" width="100%" height="100%">
<iframe
src={wechatAuthUrl}
width="100%"
height="100%"
frameBorder="0"
scrolling="no"
style={{ borderRadius: '8px' }}
/>
{(wechatStatus === "login_success" || wechatStatus === "register_success") && (
<Box position="absolute" top={0} left={0} right={0} bottom={0}
bg="rgba(0,0,0,0.7)" display="flex" alignItems="center"
justifyContent="center" borderRadius="lg">
<VStack>
<Spinner color="white" />
<Text color="white" fontWeight="bold">
{wechatStatus === "login_success" ? "正在登录..." : "正在创建账号..."}
</Text>
</VStack>
</Box>
)}
</Box>
) : wechatStatus === "expired" ? (
<VStack>
<Text color="gray.500" fontWeight="bold" mb={4}>授权已过期</Text>
<Button colorScheme="orange" size="sm" onClick={getWechatQRCode}
isLoading={isLoading}>重新获取</Button>
</VStack>
) : (
<VStack>
<Spinner size="xl" color="orange.500" />
<Text color="gray.500" fontSize="sm">加载中...</Text>
</VStack>
<Button
colorScheme="orange"
variant="outline"
size="lg"
isDisabled
opacity={0.6}
>
同意协议后显示二维码
</Button>
</VStack>
)}
</Center>
<Text textAlign="center" color={getWechatStatusColor()} fontSize="sm"
fontWeight={wechatStatus === "login_success" || wechatStatus === "register_success" ? "bold" : "normal"}>
{agreeToTerms ? getWechatStatusText() : "请先同意用户协议和隐私政策"}
{getWechatStatusText()}
</Text>
<Text fontSize="xs" color="gray.500" textAlign="center">
{agreeToTerms
? "扫码即表示同意创建账号,系统将使用您的微信昵称作为初始用户名"
: "同意协议后即可使用微信快速注册"}
扫码即表示同意创建账号系统将使用您的微信昵称作为初始用户名
</Text>
</>
)}
@@ -771,71 +710,48 @@ export default function SignUpPage() {
</FormControl>
<Divider my={2} />
{commonAuthFields}
{/* 协议同意勾选框 - 手机号注册 */}
<Box width="100%" py={3}>
<Checkbox
isChecked={agreeToTerms}
onChange={(e) => setAgreeToTerms(e.target.checked)}
colorScheme="orange"
size="sm"
>
<Text fontSize="sm" color="gray.600">
我已阅读并同意{" "}
<ChakraLink
color="orange.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "orange.600" }}
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="orange.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "orange.600" }}
>
隐私政策
</ChakraLink>
</Text>
</Checkbox>
{!agreeToTerms && (
<Text fontSize="xs" color="red.500" mt={1} ml={6}>
请先同意用户协议和隐私政策
</Text>
)}
</Box>
</>
)}
{/* 邮箱注册 */}
{registerType === 2 && (
<>
<FormControl isRequired isInvalid={!!errors.email}>
<FormLabel fontSize="sm">邮箱</FormLabel>
<InputGroup>
<InputRightElement pointerEvents="none"><Icon as={FaEnvelope} color="gray.400" /></InputRightElement>
<Input name="email" type="email" value={formData.email} onChange={handleInputChange} placeholder="请输入邮箱地址" pr="2.5rem" />
</InputGroup>
<FormErrorMessage>{errors.email}</FormErrorMessage>
</FormControl>
<FormControl isRequired isInvalid={!!errors.verificationCode}>
<FormLabel fontSize="sm">验证码</FormLabel>
<HStack>
<Input name="verificationCode" value={formData.verificationCode} onChange={handleInputChange} placeholder="请输入6位验证码" />
<Button colorScheme="orange" onClick={sendVerificationCode} isDisabled={countdown > 0 || isLoading} isLoading={isLoading && countdown === 0} minW="120px">
{countdown > 0 ? `${countdown}秒后重试` : "获取验证码"}
</Button>
</HStack>
<FormErrorMessage>{errors.verificationCode}</FormErrorMessage>
</FormControl>
<Divider my={2} />
{commonAuthFields}
</>
)}
{registerType !== 0 && (
<Button
type="submit"
width="100%"
size="lg"
background={agreeToTerms
? "linear-gradient(135deg, #ff8c00 0%, #ff6347 100%)"
: "gray.300"
}
background="linear-gradient(135deg, #ff8c00 0%, #ff6347 100%)"
color="white"
borderRadius="lg"
_hover={agreeToTerms ? {
transform: "translateY(-2px)",
boxShadow: "lg"
} : {}}
_active={agreeToTerms ? {
transform: "translateY(0)"
} : {}}
_hover={{ transform: "translateY(-2px)", boxShadow: "lg" }}
_active={{ transform: "translateY(0)" }}
isLoading={isLoading}
loadingText="注册中..."
fontWeight="bold"
isDisabled={!agreeToTerms}
cursor={agreeToTerms ? "pointer" : "not-allowed"}
>
完成注册
</Button>
@@ -854,38 +770,14 @@ export default function SignUpPage() {
<Box position="absolute" bottom={8} left="50%" transform="translateX(-50%)" zIndex={1}>
<HStack spacing={6}>
<ChakraLink
color="white"
fontSize="sm"
opacity={0.8}
_hover={{ opacity: 1 }}
onClick={onUserAgreementModalOpen}
>
用户协议
</ChakraLink>
<ChakraLink
color="white"
fontSize="sm"
opacity={0.8}
_hover={{ opacity: 1 }}
onClick={onPrivacyModalOpen}
>
隐私政策
</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>Company</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>About Us</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>Team</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>Products</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>Blog</ChakraLink>
<ChakraLink href="#" color="white" fontSize="sm" opacity={0.8} _hover={{ opacity: 1 }}>Pricing</ChakraLink>
</HStack>
</Box>
{/* 隐私政策弹窗 */}
<PrivacyPolicyModal
isOpen={isPrivacyModalOpen}
onClose={onPrivacyModalClose}
/>
{/* 用户协议弹窗 */}
<UserAgreementModal
isOpen={isUserAgreementModalOpen}
onClose={onUserAgreementModalClose}
/>
</Flex>
);
}

View File

@@ -0,0 +1,319 @@
/* 评论区域样式 */
.comment-section {
background: #fff;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* 评论编辑器 */
.comment-editor {
margin-bottom: 24px;
background: #fafafa;
border-radius: 8px;
padding: 16px;
border: 1px solid #e8e8e8;
}
/* 工具栏样式 */
.comment-toolbar {
margin-bottom: 12px;
padding: 8px 12px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px 6px 0 0;
border-bottom: none;
}
.comment-toolbar .ant-btn {
border: none;
box-shadow: none;
color: #666;
transition: all 0.2s;
}
.comment-toolbar .ant-btn:hover {
color: #ff9500;
background: rgba(255, 149, 0, 0.1);
}
/* 评论输入框 */
.comment-input {
border-radius: 0 0 6px 6px !important;
border-top: none !important;
resize: vertical;
}
.comment-input:focus {
border-color: #ff9500 !important;
box-shadow: 0 0 0 2px rgba(255, 149, 0, 0.2) !important;
}
/* 表情选择器样式 */
.emoji-picker {
width: 280px;
max-height: 200px;
overflow-y: auto;
padding: 8px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
}
.emoji-button {
width: 24px !important;
height: 24px !important;
padding: 0 !important;
font-size: 16px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 4px !important;
transition: all 0.2s !important;
}
.emoji-button:hover {
background-color: #f0f0f0 !important;
transform: scale(1.1);
}
/* 评论操作区域 */
.comment-actions {
margin-top: 12px;
text-align: right;
}
.comment-actions .ant-btn-primary {
background: #ff9500;
border-color: #ff9500;
}
.comment-actions .ant-btn-primary:hover {
background: #e6860e;
border-color: #e6860e;
}
/* 评论列表头部 */
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 20px 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.sort-buttons .ant-radio-button-wrapper {
border-color: #d9d9d9;
color: #666;
}
.sort-buttons .ant-radio-button-wrapper-checked {
background: #ff9500;
border-color: #ff9500;
color: #fff;
}
/* 评论项样式 */
.comment-item {
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.comment-item:last-child {
border-bottom: none;
}
.main-comment {
background: #fff;
}
.reply-item {
background: #fafafa;
margin-left: 48px;
padding: 12px;
border-radius: 6px;
margin-top: 8px;
border: 1px solid #f0f0f0;
}
/* 评论内容 */
.comment-content {
margin-bottom: 8px;
line-height: 1.6;
word-wrap: break-word;
}
/* 格式化文本样式 */
.comment-text strong,
.comment-text b {
font-weight: bold;
color: #262626;
}
.comment-text em,
.comment-text i {
font-style: italic;
color: #595959;
}
.comment-text u {
text-decoration: underline;
color: #722ed1;
}
.comment-text code {
background: #f5f5f5 !important;
border: 1px solid #d9d9d9 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace !important;
font-size: 0.9em !important;
color: #d63384 !important;
}
.comment-text a {
color: #1890ff !important;
text-decoration: none !important;
transition: color 0.2s;
}
.comment-text a:hover {
color: #40a9ff !important;
text-decoration: underline !important;
}
/* 列表样式 */
.comment-text ol,
.comment-text ul {
margin: 8px 0;
padding-left: 20px;
}
.comment-text li {
margin: 4px 0;
line-height: 1.5;
}
.comment-content a:hover {
text-decoration: underline;
}
/* 评论元信息 */
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
color: #8e8e93;
font-size: 12px;
}
.comment-time {
color: #999;
}
/* 评论操作按钮 */
.comment-actions-buttons .ant-btn {
color: #8e8e93;
border: none;
padding: 4px 8px;
height: auto;
font-size: 12px;
}
.comment-actions-buttons .ant-btn:hover {
color: #ff9500;
background: rgba(255, 149, 0, 0.1);
}
/* 点赞按钮特殊样式 */
.comment-actions-buttons .ant-btn.liked {
color: #ff4d4f;
}
/* 回复区域 */
.replies-section {
margin-top: 12px;
border-left: 2px solid #f0f0f0;
padding-left: 16px;
}
/* 空状态样式 */
.ant-empty {
margin: 40px 0;
}
/* 加载状态 */
.ant-spin-container {
min-height: 200px;
}
/* 响应式样式 */
@media (max-width: 768px) {
.comment-section {
padding: 12px;
margin: 12px 0;
}
.comment-editor {
padding: 12px;
}
.comment-toolbar {
padding: 6px 8px;
}
.reply-item {
margin-left: 32px;
padding: 8px;
}
.comments-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
/* 回复提示样式 */
.reply-to-info {
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 12px;
color: #1890ff;
}
/* 动画效果 */
.comment-item {
transition: all 0.3s ease;
}
.comment-item:hover {
background: rgba(0, 0, 0, 0.02);
}
/* 滚动条样式 */
.comment-input::-webkit-scrollbar {
width: 6px;
}
.comment-input::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.comment-input::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.comment-input::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

View File

@@ -0,0 +1,690 @@
// src/views/Community/components/CommentSection.js
import React, { useState, useEffect, useRef } from 'react';
import {
Input,
Button,
List,
Avatar,
Space,
Typography,
Divider,
Empty,
Spin,
Tooltip,
Tag,
Modal,
message,
Popover
} from 'antd';
import { useNavigate } from 'react-router-dom';
import {
BoldOutlined,
ItalicOutlined,
UnderlineOutlined,
CodeOutlined,
LinkOutlined,
OrderedListOutlined,
UnorderedListOutlined,
SmileOutlined,
SendOutlined,
LikeOutlined,
DislikeOutlined,
MessageOutlined,
MoreOutlined,
DeleteOutlined
} from '@ant-design/icons';
import moment from 'moment';
// 直接定义API调用来避免缓存问题
// 判断当前是否是生产环境
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
// 生成唯一的会话ID
const generateSessionId = () => {
let sessionId = localStorage.getItem('user_session_id');
if (!sessionId) {
sessionId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('user_session_id', sessionId);
}
return sessionId;
};
const commentAPI = {
getComments: async (eventId, sortType = 'latest') => {
try {
const sessionId = generateSessionId();
const response = await fetch(`${API_BASE_URL}/api/events/${eventId}/comments?sort=${sortType}&session_id=${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
return await response.json();
} catch (error) {
console.error('获取评论失败:', error);
return { success: false, data: [] };
}
},
addComment: async (eventId, commentData) => {
try {
const response = await fetch(`${API_BASE_URL}/api/events/${eventId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(commentData)
});
return await response.json();
} catch (error) {
console.error('添加评论失败:', error);
return { success: false, message: '添加评论失败' };
}
},
likeComment: async (commentId) => {
try {
const sessionId = generateSessionId();
const response = await fetch(`${API_BASE_URL}/api/comments/${commentId}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ session_id: sessionId })
});
return await response.json();
} catch (error) {
console.error('点赞失败:', error);
return { success: false, message: '点赞失败' };
}
},
deleteComment: async (commentId) => {
try {
const response = await fetch(`${API_BASE_URL}/api/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
return await response.json();
} catch (error) {
console.error('删除评论失败:', error);
return { success: false, message: '删除评论失败' };
}
}
};
import './CommentSection.css';
const { TextArea } = Input;
const { Text } = Typography;
const CommentSection = ({ eventId, title = "社区讨论" }) => {
const navigate = useNavigate();
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [commentText, setCommentText] = useState('');
const [replyTo, setReplyTo] = useState(null);
const [sortType, setSortType] = useState('latest'); // 'latest' or 'hot'
const [expandedComments, setExpandedComments] = useState(new Set());
const [emojiVisible, setEmojiVisible] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [userLoading, setUserLoading] = useState(true);
const textareaRef = useRef(null);
// 表情数据
const emojis = [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙',
'😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔',
'🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
'😔', '😕', '🙁', '😖', '😣', '😞', '😓', '😩', '😫', '🥱',
'😤', '😡', '😠', '🤬', '😈', '👿', '💀', '☠️', '💩', '🤡',
'👍', '👎', '👌', '🤞', '✌️', '🤟', '🤘', '👊', '✊', '🤛',
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
'❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '💯'
];
// 加载评论
const loadComments = async () => {
if (!eventId) return;
setLoading(true);
try {
const response = await commentAPI.getComments(eventId, sortType);
if (response.success) {
setComments(response.data || []);
}
} catch (error) {
console.error('加载评论失败:', error);
message.error('加载评论失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
loadCurrentUser();
}, [eventId, sortType]);
// 加载当前用户信息
const loadCurrentUser = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/user/current`, {
credentials: 'include'
});
const data = await response.json();
if (data.success && data.data) {
setCurrentUser(data.data);
} else {
setCurrentUser({ is_authenticated: false });
}
} catch (error) {
console.error('获取用户信息失败:', error);
setCurrentUser({ is_authenticated: false });
} finally {
setUserLoading(false);
}
};
// 文本格式化功能
const insertFormatting = (startTag, endTag = null, placeholder = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
// 获取实际的 textarea 元素Ant Design TextArea 的内部元素)
const textareaElement = textarea.resizableTextArea?.textArea || textarea;
const start = textareaElement.selectionStart || 0;
const end = textareaElement.selectionEnd || 0;
const selectedText = commentText.substring(start, end);
if (endTag) {
// 需要结束标签的格式化(如 **bold**
const newText = selectedText || placeholder;
const formatted = startTag + newText + endTag;
setCommentText(prev =>
prev.substring(0, start) + formatted + prev.substring(end)
);
// 设置光标位置
setTimeout(() => {
const newStart = selectedText ? start + formatted.length : start + startTag.length;
if (textareaElement.setSelectionRange) {
textareaElement.setSelectionRange(newStart, newStart);
}
textareaElement.focus();
}, 0);
} else {
// 不需要结束标签的格式化(如链接)
setCommentText(prev =>
prev.substring(0, start) + startTag + prev.substring(end)
);
setTimeout(() => {
const newStart = start + startTag.length;
if (textareaElement.setSelectionRange) {
textareaElement.setSelectionRange(newStart, newStart);
}
textareaElement.focus();
}, 0);
}
};
const formatBold = () => insertFormatting('**', '**', '粗体文本');
const formatItalic = () => insertFormatting('*', '*', '斜体文本');
const formatUnderline = () => insertFormatting('<u>', '</u>', '下划线文本');
const formatCode = () => insertFormatting('`', '`', '代码');
const formatLink = () => {
const url = prompt('请输入链接地址:');
if (url) {
const text = prompt('请输入链接文本:') || url;
insertFormatting(`[${text}](${url})`);
}
};
const formatOrderedList = () => insertFormatting('\n1. ');
const formatUnorderedList = () => insertFormatting('\n- ');
const insertEmoji = (emoji) => {
insertFormatting(emoji);
setEmojiVisible(false);
};
// 表情选择面板
const renderEmojiPicker = () => (
<div className="emoji-picker">
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<Button
key={index}
type="text"
size="small"
className="emoji-button"
onClick={() => insertEmoji(emoji)}
>
{emoji}
</Button>
))}
</div>
</div>
);
// 提交评论
const handleSubmitComment = async () => {
if (!commentText.trim()) {
message.warning('请输入评论内容');
return;
}
setSubmitting(true);
try {
const response = await commentAPI.addComment(eventId, {
content: commentText,
parent_id: replyTo?.id || null
});
if (response.success) {
message.success('评论发布成功');
setCommentText('');
setReplyTo(null);
loadComments(); // 重新加载评论
}
} catch (error) {
console.error('发布评论失败:', error);
message.error('发布评论失败');
} finally {
setSubmitting(false);
}
};
// 处理点赞
const handleLike = async (commentId) => {
try {
const response = await commentAPI.likeComment(commentId);
if (response.success) {
message.success(response.message);
loadComments(); // 重新加载以更新点赞数
} else {
message.error(response.message || '操作失败');
}
} catch (error) {
console.error('点赞失败:', error);
message.error('操作失败');
}
};
// 处理删除评论
const handleDelete = async (commentId) => {
try {
const response = await commentAPI.deleteComment(commentId);
if (response.success) {
message.success('评论删除成功');
loadComments(); // 重新加载评论列表
} else {
message.error(response.message || '删除失败');
}
} catch (error) {
console.error('删除评论失败:', error);
message.error('删除失败');
}
};
// 回复处理
const handleReply = (comment) => {
setReplyTo(comment);
setCommentText(`@${comment.author} `);
if (textareaRef.current) {
const textareaElement = textareaRef.current.resizableTextArea?.textArea || textareaRef.current;
textareaElement.focus();
}
};
// 取消回复
const cancelReply = () => {
setReplyTo(null);
setCommentText('');
};
// 切换回复展开/折叠
const toggleReplies = (commentId) => {
const newExpanded = new Set(expandedComments);
if (newExpanded.has(commentId)) {
newExpanded.delete(commentId);
} else {
newExpanded.add(commentId);
}
setExpandedComments(newExpanded);
};
// 解析和渲染格式化文本
const parseFormattedText = (text) => {
if (!text) return text;
// 替换规则数组
const formatRules = [
// 粗体 **text**
{
regex: /\*\*(.*?)\*\*/g,
replacement: (match, content) => `<strong>${content}</strong>`
},
// 斜体 *text* (但不匹配 **text**)
{
regex: /(?<!\*)\*([^*\n]+?)\*(?!\*)/g,
replacement: (match, content) => `<em>${content}</em>`
},
// 下划线 __text__
{
regex: /__(.*?)__/g,
replacement: (match, content) => `<u>${content}</u>`
},
// 代码 `code`
{
regex: /`([^`]+?)`/g,
replacement: (match, content) => `<code>${content}</code>`
},
// 链接 [text](url)
{
regex: /\[([^\]]+?)\]\(([^)]+?)\)/g,
replacement: (match, text, url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`
},
// 有序列表 1. item
{
regex: /^(\d+\.\s.+)$/gm,
replacement: (match, content) => `<li>${content.replace(/^\d+\.\s/, '')}</li>`
},
// 无序列表 - item
{
regex: /^(-\s.+)$/gm,
replacement: (match, content) => `<li>${content.replace(/^-\s/, '')}</li>`
},
// 换行
{
regex: /\n/g,
replacement: '<br/>'
}
];
let formattedText = text;
// 应用所有格式化规则
formatRules.forEach(rule => {
formattedText = formattedText.replace(rule.regex, rule.replacement);
});
// 后处理:包装列表项
// 包装有序列表
formattedText = formattedText.replace(
/(<li>.*?<\/li>(?:<br\/>)*)+/g,
(match) => {
// 检查是否包含数字开头的列表项(通过原始文本判断)
const hasOrderedItems = /^\d+\.\s/.test(text);
const listTag = hasOrderedItems ? 'ol' : 'ul';
const cleanedMatch = match.replace(/<br\/>/g, '');
return `<${listTag}>${cleanedMatch}</${listTag}>`;
}
);
return formattedText;
};
// 渲染工具栏
const renderToolbar = () => (
<div className="comment-toolbar">
<Space size={4}>
<Tooltip title="加粗">
<Button type="text" size="small" icon={<BoldOutlined />} onClick={formatBold} />
</Tooltip>
<Tooltip title="斜体">
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={formatItalic} />
</Tooltip>
<Tooltip title="下划线">
<Button type="text" size="small" icon={<UnderlineOutlined />} onClick={formatUnderline} />
</Tooltip>
<Tooltip title="代码">
<Button type="text" size="small" icon={<CodeOutlined />} onClick={formatCode} />
</Tooltip>
<Tooltip title="有序列表">
<Button type="text" size="small" icon={<OrderedListOutlined />} onClick={formatOrderedList} />
</Tooltip>
<Tooltip title="无序列表">
<Button type="text" size="small" icon={<UnorderedListOutlined />} onClick={formatUnorderedList} />
</Tooltip>
<Tooltip title="链接">
<Button type="text" size="small" icon={<LinkOutlined />} onClick={formatLink} />
</Tooltip>
<Popover
content={renderEmojiPicker()}
title="选择表情"
trigger="click"
open={emojiVisible}
onOpenChange={setEmojiVisible}
placement="topLeft"
>
<Tooltip title="表情">
<Button type="text" size="small" icon={<SmileOutlined />} />
</Tooltip>
</Popover>
</Space>
</div>
);
// 渲染评论输入框
const renderCommentEditor = () => (
<div className="comment-editor">
{renderToolbar()}
<TextArea
ref={textareaRef}
placeholder={replyTo ? `回复 @${replyTo.author}` : "写下你的想法..."}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={4}
className="comment-input"
style={{
border: '1px solid #d9d9d9',
borderRadius: '6px',
fontSize: '14px'
}}
/>
<div className="comment-actions">
<Space>
{replyTo && (
<Button
size="small"
onClick={cancelReply}
>
取消回复
</Button>
)}
<Button
type="primary"
size="small"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmitComment}
style={{
backgroundColor: '#ff9500',
borderColor: '#ff9500'
}}
>
发布
</Button>
</Space>
</div>
</div>
);
// 渲染单个评论
const renderComment = (comment, isReply = false) => (
<List.Item
key={comment.id}
className={`comment-item ${isReply ? 'reply-item' : 'main-comment'}`}
>
<List.Item.Meta
avatar={
<Avatar
size={isReply ? 32 : 40}
src={comment.avatar}
style={{ backgroundColor: '#1890ff' }}
>
{comment.author?.charAt(0)?.toUpperCase() || 'U'}
</Avatar>
}
title={
<Space>
<Text strong>{comment.author || '匿名用户'}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{moment(comment.created_at).fromNow()}
</Text>
</Space>
}
description={
<div className="comment-content">
<div
className="comment-text"
dangerouslySetInnerHTML={{
__html: parseFormattedText(comment.content)
}}
/>
<div className="comment-actions-bar">
<Space size={16}>
<Button
type="text"
size="small"
icon={<LikeOutlined />}
onClick={() => handleLike(comment.id)}
style={{
color: comment.user_liked ? '#ff4d4f' : '#8e8e93'
}}
>
{comment.likes || 0}
</Button>
<Button
type="text"
size="small"
icon={<MessageOutlined />}
onClick={() => handleReply(comment)}
>
回复
</Button>
{comment.can_delete && (
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDelete(comment.id)}
style={{ color: '#ff4d4f' }}
>
删除
</Button>
)}
{comment.replies && comment.replies.length > 0 && (
<Button
type="text"
size="small"
onClick={() => toggleReplies(comment.id)}
>
{expandedComments.has(comment.id) ? '收起' : '展开'}
{comment.replies.length}条回复
</Button>
)}
</Space>
</div>
{/* 渲染回复 */}
{comment.replies && comment.replies.length > 0 && expandedComments.has(comment.id) && (
<div className="replies-section">
{comment.replies.map(reply => renderComment(reply, true))}
</div>
)}
</div>
}
/>
</List.Item>
);
return (
<div className="comment-section">
<Divider orientation="left" style={{ margin: '24px 0 16px 0' }}>
<Text strong style={{ fontSize: '16px' }}>{title}</Text>
</Divider>
{/* 评论输入区 */}
{userLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />
</div>
) : currentUser?.is_authenticated ? (
renderCommentEditor()
) : (
<div style={{
textAlign: 'center',
padding: '40px 20px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #e8e8e8'
}}>
<Text type="secondary">请先登录后再发表评论</Text>
<br />
<Button
type="primary"
style={{ marginTop: '12px', background: '#ff9500', borderColor: '#ff9500' }}
onClick={() => navigate('/auth/sign-in')}
>
立即登录
</Button>
</div>
)}
{/* 评论列表头部 */}
<div className="comments-header">
<Space split={<Divider type="vertical" />}>
<Text strong>全部回复</Text>
<Button
type="text"
size="small"
className={sortType === 'latest' ? 'active' : ''}
onClick={() => setSortType('latest')}
>
最新
</Button>
<Button
type="text"
size="small"
className={sortType === 'hot' ? 'active' : ''}
onClick={() => setSortType('hot')}
>
热门
</Button>
</Space>
</div>
{/* 评论列表 */}
<Spin spinning={loading}>
{comments.length > 0 ? (
<List
className="comments-list"
itemLayout="vertical"
dataSource={comments}
renderItem={(comment) => renderComment(comment)}
/>
) : (
<Empty
description="暂无社区讨论"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ margin: '40px 0' }}
/>
)}
</Spin>
</div>
);
};
export default CommentSection;

View File

@@ -1,16 +1,12 @@
// src/views/Community/components/EventDetailModal.js
import React, { useState, useEffect } from 'react';
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty } from 'antd';
import { eventService } from '../../../services/eventService';
import moment from 'moment';
const EventDetailModal = ({ visible, event, onClose }) => {
const [loading, setLoading] = useState(false);
const [eventDetail, setEventDetail] = useState(null);
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [comments, setComments] = useState([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const loadEventDetail = async () => {
if (!event) return;
@@ -28,27 +24,9 @@ const EventDetailModal = ({ visible, event, onClose }) => {
}
};
const loadComments = async () => {
if (!event) return;
setCommentsLoading(true);
try {
// 使用统一的posts API获取评论
const result = await eventService.getPosts(event.id);
if (result.success) {
setComments(result.data || []);
}
} catch (error) {
console.error('Failed to load comments:', error);
} finally {
setCommentsLoading(false);
}
};
useEffect(() => {
if (visible && event) {
loadEventDetail();
loadComments();
}
}, [visible, event]);
@@ -75,34 +53,6 @@ const EventDetailModal = ({ visible, event, onClose }) => {
);
};
const handleSubmitComment = async () => {
if (!commentText.trim()) {
message.warning('请输入评论内容');
return;
}
setSubmitting(true);
try {
// 使用统一的createPost API
const result = await eventService.createPost(event.id, {
content: commentText.trim(),
content_type: 'text'
});
if (result.success) {
message.success('评论发布成功');
setCommentText('');
// 重新加载评论列表
loadComments();
} else {
throw new Error(result.message || '评论失败');
}
} catch (e) {
message.error(e.message || '评论失败');
} finally {
setSubmitting(false);
}
};
return (
<Modal
title={eventDetail?.title || '事件详情'}
@@ -133,7 +83,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
</Descriptions.Item>
<Descriptions.Item label="事件描述" span={2}>
{eventDetail.description}AI合成
{eventDetail.description}
</Descriptions.Item>
</Descriptions>
@@ -155,23 +105,10 @@ const EventDetailModal = ({ visible, event, onClose }) => {
size="small"
dataSource={eventDetail.related_stocks}
renderItem={stock => (
<List.Item
actions={[
<Button
type="primary"
size="small"
onClick={() => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
}}
>
股票详情
</Button>
]}
>
<List.Item>
<List.Item.Meta
title={`${stock.stock_name} (${stock.stock_code})`}
description={stock.relation_desc ? `${stock.relation_desc}AI合成` : ''}
description={stock.relation_desc}
/>
{stock.change !== null && (
<Tag color={stock.change > 0 ? 'red' : 'green'}>
@@ -183,69 +120,6 @@ const EventDetailModal = ({ visible, event, onClose }) => {
/>
</div>
)}
{/* 讨论区 */}
<div style={{ marginTop: 24 }}>
<h4>讨论区</h4>
{/* 评论列表 */}
<div style={{ marginBottom: 24 }}>
<Spin spinning={commentsLoading}>
{comments.length === 0 ? (
<Empty
description="暂无评论"
style={{ padding: '20px 0' }}
/>
) : (
<List
itemLayout="vertical"
dataSource={comments}
renderItem={comment => (
<List.Item key={comment.id}>
<List.Item.Meta
title={
<div style={{ fontSize: '14px' }}>
<strong>{comment.author?.username || 'Anonymous'}</strong>
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
{moment(comment.created_at).format('MM-DD HH:mm')}
</span>
</div>
}
description={
<div style={{
fontSize: '14px',
lineHeight: '1.6',
marginTop: 8
}}>
{comment.content}
</div>
}
/>
</List.Item>
)}
/>
)}
</Spin>
</div>
{/* 评论输入框登录后可用未登录后端会返回401 */}
<div>
<h4>发表评论</h4>
<Input.TextArea
placeholder="说点什么..."
rows={3}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
maxLength={500}
showCount
/>
<div style={{ textAlign: 'right', marginTop: 8 }}>
<Button type="primary" loading={submitting} onClick={handleSubmitComment}>
发布
</Button>
</div>
</div>
</div>
</>
)}
</Spin>

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@ const HotEvents = ({ events }) => {
};
const handleCardClick = (eventId) => {
navigate(`/event-detail/${eventId}`);
navigate(`/admin/event-detail/${eventId}`);
};
return (
@@ -59,10 +59,10 @@ const HotEvents = ({ events }) => {
<div className="event-cover">
<img
alt={event.title}
src={`/images/events/${['first', 'second', 'third', 'fourth'][index] || 'first'}.jpg`}
src={`/assets/img/podcast/podcast-${index + 1}.jpeg`}
onError={e => {
e.target.onerror = null;
e.target.src = defaultEventImage;
e.target.src = defaultEventImage; // <-- 2. 在这里使用导入的变量
}}
/>
{event.importance && (

View File

@@ -2,28 +2,22 @@
import React, { useState, useEffect } from 'react';
import {
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
Drawer, Typography, Divider, Space, Tooltip, message, Alert
Drawer, Typography, Divider, Space, Tooltip, message
} from 'antd';
import {
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined
StarFilled, CalendarOutlined, LinkOutlined, StockOutlined,
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined
} from '@ant-design/icons';
import moment from 'moment';
import ReactMarkdown from 'react-markdown';
import { eventService, stockService } from '../../../services/eventService';
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
import { useSubscription } from '../../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
import StockKlineModal from './StockKlineModal'; // 需要创建这个组件
import './InvestmentCalendar.css';
const { TabPane } = Tabs;
const { Text, Title, Paragraph } = Typography;
const InvestmentCalendar = () => {
// 权限控制
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [eventCounts, setEventCounts] = useState([]);
const [selectedDate, setSelectedDate] = useState(null);
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
@@ -39,9 +33,6 @@ const InvestmentCalendar = () => {
const [stockQuotes, setStockQuotes] = useState({});
const [klineModalVisible, setKlineModalVisible] = useState(false);
const [selectedStock, setSelectedStock] = useState(null);
const [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表
const [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
// 加载月度事件统计
const loadEventCounts = async (date) => {
@@ -76,41 +67,11 @@ const InvestmentCalendar = () => {
}
};
// 获取六位股票代码(去掉后缀)
const getSixDigitCode = (code) => {
if (!code) return code;
// 如果有.SH或.SZ后缀去掉
return code.split('.')[0];
};
// 加载股票行情
const loadStockQuotes = async (stocks, eventTime) => {
try {
const codes = stocks.map(stock => getSixDigitCode(stock[0])); // 确保使用六位代码
const quotes = {};
// 使用市场API获取最新行情数据
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
const originalCode = stocks[i][0]; // 保持原始代码作为key
try {
const response = await fetch(`/api/market/trade/${code}?days=1`);
if (response.ok) {
const data = await response.json();
if (data.success && data.data && data.data.length > 0) {
const latest = data.data[data.data.length - 1]; // 最新数据
quotes[originalCode] = {
price: latest.close,
change: latest.change_amount,
changePercent: latest.change_percent
};
}
}
} catch (err) {
console.error(`Failed to load quote for ${code}:`, err);
}
}
const codes = stocks.map(stock => stock[0]); // stock[0] 是股票代码
const quotes = await stockService.getQuotes(codes, eventTime);
setStockQuotes(quotes);
} catch (error) {
console.error('Failed to load stock quotes:', error);
@@ -192,91 +153,24 @@ const InvestmentCalendar = () => {
// 显示相关股票
const showRelatedStocks = (stocks, eventTime) => {
// 检查权限
if (!hasFeatureAccess('related_stocks')) {
setUpgradeModalOpen(true);
return;
}
if (!stocks || stocks.length === 0) {
message.info('暂无相关股票');
return;
}
// 按相关度排序(限降序)
const sortedStocks = [...stocks].sort((a, b) => (b[3] || 0) - (a[3] || 0));
setSelectedStocks(sortedStocks);
setSelectedStocks(stocks);
setStockModalVisible(true);
loadStockQuotes(sortedStocks, eventTime);
loadStockQuotes(stocks, eventTime);
};
// 显示K线图
const showKline = (stock) => {
setSelectedStock({
code: getSixDigitCode(stock[0]), // 确保使用六位代码
code: stock[0],
name: stock[1]
});
setKlineModalVisible(true);
};
// 处理关注切换
const handleFollowToggle = async (eventId) => {
setFollowingIds(prev => [...prev, eventId]);
try {
const response = await eventService.calendar.toggleFollow(eventId);
if (response.success) {
// 更新本地事件列表的关注状态
setSelectedDateEvents(prev =>
prev.map(event =>
event.id === eventId
? { ...event, is_following: response.data.is_following }
: event
)
);
message.success(response.data.is_following ? '关注成功' : '取消关注成功');
} else {
message.error(response.error || '操作失败');
}
} catch (error) {
console.error('关注操作失败:', error);
message.error('操作失败,请重试');
} finally {
setFollowingIds(prev => prev.filter(id => id !== eventId));
}
};
// 添加单只股票到自选
const addSingleToWatchlist = async (stock) => {
const stockCode = getSixDigitCode(stock[0]);
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true }));
try {
const response = await fetch('/api/account/watchlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
stock_code: stockCode, // 使用六位代码
stock_name: stock[1] // 股票名称
})
});
const data = await response.json();
if (data.success) {
message.success(`已将 ${stock[1]}(${stockCode}) 添加到自选`);
} else {
message.error(data.error || '添加失败');
}
} catch (error) {
console.error(`添加${stock[1]}(${stockCode})到自选失败:`, error);
message.error('添加失败,请重试');
} finally {
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: false }));
}
};
// 事件表格列定义
const eventColumns = [
{
@@ -319,43 +213,46 @@ const InvestmentCalendar = () => {
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => showContentDetail(text + (text ? '\n\n(AI合成)' : ''), '事件背景')}
onClick={() => showContentDetail(text, '事件背景')}
disabled={!text}
>
查看
</Button>
)
},
{
title: (
<span>
相关股票
{!hasFeatureAccess('related_stocks') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6, color: '#faad14' }} />
)}
</span>
),
title: '预测',
dataIndex: 'forecast',
key: 'forecast',
width: 80,
render: (text) => (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => showContentDetail(text, '事件预测')}
disabled={!text}
>
查看
</Button>
)
},
{
title: '相关股票',
dataIndex: 'related_stocks',
key: 'stocks',
width: 100,
render: (stocks, record) => {
const hasStocks = stocks && stocks.length > 0;
const hasAccess = hasFeatureAccess('related_stocks');
return (
<Button
type="link"
size="small"
icon={hasAccess ? <StockOutlined /> : <LockOutlined />}
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
disabled={!hasStocks}
style={!hasAccess ? { color: '#faad14' } : {}}
>
{hasStocks ? (hasAccess ? `${stocks.length}` : '🔒需Pro') : '无'}
</Button>
);
}
render: (stocks, record) => (
<Button
type="link"
size="small"
icon={<StockOutlined />}
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
disabled={!stocks || stocks.length === 0}
>
{stocks && stocks.length > 0 ? `${stocks.length}` : '无'}
</Button>
)
},
{
title: '相关概念',
@@ -378,20 +275,6 @@ const InvestmentCalendar = () => {
)}
</Space>
)
},
{
title: '关注',
key: 'follow',
width: 60,
render: (_, record) => (
<Button
type={record.is_following ? "primary" : "default"}
icon={record.is_following ? <StarFilled /> : <StarOutlined />}
size="small"
onClick={() => handleFollowToggle(record.id)}
loading={followingIds.includes(record.id)}
/>
)
}
];
@@ -402,36 +285,14 @@ const InvestmentCalendar = () => {
dataIndex: '0',
key: 'code',
width: 100,
render: (code) => {
const sixDigitCode = getSixDigitCode(code);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
>
<Text code>{sixDigitCode}</Text>
</a>
);
}
render: (code) => <Text code>{code}</Text>
},
{
title: '名称',
dataIndex: '1',
key: 'name',
width: 100,
render: (name, record) => {
const sixDigitCode = getSixDigitCode(record[0]);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
>
<Text strong>{name}</Text>
</a>
);
}
render: (name) => <Text strong>{name}</Text>
},
{
title: '现价',
@@ -439,14 +300,14 @@ const InvestmentCalendar = () => {
width: 80,
render: (_, record) => {
const quote = stockQuotes[record[0]];
if (quote && quote.price !== undefined) {
if (quote) {
return (
<Text type={quote.change > 0 ? 'danger' : 'success'}>
{quote.price?.toFixed(2)}
</Text>
);
}
return <Text>-</Text>;
return <Spin size="small" />;
}
},
{
@@ -455,7 +316,7 @@ const InvestmentCalendar = () => {
width: 100,
render: (_, record) => {
const quote = stockQuotes[record[0]];
if (quote && quote.changePercent !== undefined) {
if (quote) {
const changePercent = quote.changePercent || 0;
return (
<Tag color={changePercent > 0 ? 'red' : 'green'}>
@@ -463,51 +324,46 @@ const InvestmentCalendar = () => {
</Tag>
);
}
return <Text>-</Text>;
return <Spin size="small" />;
}
},
{
title: '关联理由',
dataIndex: '2',
key: 'reason',
render: (reason, record) => {
const stockCode = record[0];
const isExpanded = expandedReasons[stockCode] || false;
const shouldTruncate = reason && reason.length > 100;
const toggleExpanded = () => {
setExpandedReasons(prev => ({
...prev,
[stockCode]: !prev[stockCode]
}));
};
ellipsis: true,
render: (reason) => (
<Tooltip title={reason}>
<Paragraph ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{reason}
</Paragraph>
</Tooltip>
)
},
{
title: '相关度',
dataIndex: '3',
key: 'relevance',
width: 100,
render: (relevance) => {
const percent = (relevance * 100).toFixed(0);
return (
<div>
<Text>
{isExpanded || !shouldTruncate
? reason
: `${reason?.slice(0, 100)}...`
}
</Text>
{shouldTruncate && (
<Button
type="link"
size="small"
onClick={toggleExpanded}
style={{ padding: 0, marginLeft: 4 }}
>
({isExpanded ? '收起' : '展开'})
</Button>
)}
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: '12px' }}>(AI合成)</Text>
<Tooltip title={`相关度: ${percent}%`}>
<div style={{ width: '100%', height: 20, background: '#f0f0f0', borderRadius: 10 }}>
<div
style={{
width: `${percent}%`,
height: '100%',
background: relevance > 0.8 ? '#52c41a' : '#1890ff',
borderRadius: 10,
transition: 'width 0.3s'
}}
/>
</div>
</div>
</Tooltip>
);
}
},
{
title: 'K线图',
key: 'kline',
@@ -521,26 +377,6 @@ const InvestmentCalendar = () => {
查看
</Button>
)
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => {
const stockCode = getSixDigitCode(record[0]);
const isAdding = addingToWatchlist[stockCode] || false;
return (
<Button
type="default"
size="small"
loading={isAdding}
onClick={() => addSingleToWatchlist(record)}
>
加自选
</Button>
);
}
}
];
@@ -623,71 +459,30 @@ const InvestmentCalendar = () => {
<Space>
<StockOutlined />
<span>相关股票</span>
{!hasFeatureAccess('related_stocks') && (
<LockOutlined style={{ color: '#faad14' }} />
)}
</Space>
}
visible={stockModalVisible}
onCancel={() => {
setStockModalVisible(false);
setExpandedReasons({}); // 清理展开状态
setAddingToWatchlist({}); // 清理加自选状态
}}
onCancel={() => setStockModalVisible(false)}
width={1000}
footer={
<Button onClick={() => setStockModalVisible(false)}>
关闭
</Button>
}
footer={null}
>
{hasFeatureAccess('related_stocks') ? (
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record[0]}
size="middle"
pagination={false}
/>
) : (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
<LockOutlined />
</div>
<Alert
message="相关股票功能已锁定"
description="此功能需要Pro版订阅才能使用"
type="warning"
showIcon
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
/>
<Button
type="primary"
size="large"
onClick={() => setUpgradeModalOpen(true)}
>
升级到 Pro版
</Button>
</div>
)}
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record[0]}
size="middle"
pagination={false}
/>
</Modal>
{/* K线图模态框 */}
{klineModalVisible && selectedStock && (
<StockChartAntdModal
open={klineModalVisible}
<StockKlineModal
visible={klineModalVisible}
stock={selectedStock}
onCancel={() => setKlineModalVisible(false)}
onClose={() => setKlineModalVisible(false)}
/>
)}
{/* 订阅升级模态框 */}
<SubscriptionUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
requiredLevel="pro"
featureName="相关股票分析"
/>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
// src/views/Community/components/StockKlineModal.js
import React, { useState, useEffect } from 'react';
import { Modal, Spin, Radio, message } from 'antd';
import ReactECharts from 'echarts-for-react';
import { stockService } from '../../../services/eventService';
import moment from 'moment';
const StockKlineModal = ({ visible, stock, onClose, eventTime }) => {
const [loading, setLoading] = useState(false);
const [klineData, setKlineData] = useState(null);
const [chartType, setChartType] = useState('kline'); // kline or timeline
// 加载K线数据
const loadKlineData = async () => {
if (!stock?.code) return;
setLoading(true);
try {
const response = await stockService.getKlineData(stock.code, chartType, eventTime);
if (response.success) {
setKlineData(response.data);
} else {
message.error('加载K线数据失败');
}
} catch (error) {
console.error('Failed to load kline data:', error);
message.error('加载K线数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible && stock) {
loadKlineData();
}
}, [visible, stock, chartType]);
// 获取K线图配置
const getKlineOption = () => {
if (!klineData || !klineData.data || klineData.data.length === 0) {
return {};
}
const dates = klineData.data.map(item => item[0]);
const data = klineData.data.map(item => [
item[1], // 开盘价
item[2], // 收盘价
item[3], // 最低价
item[4], // 最高价
item[5] // 成交量
]);
const volumes = klineData.data.map((item, index) => ({
value: item[5],
itemStyle: {
color: item[2] >= item[1] ? '#ef5350' : '#26a69a'
}
}));
// 事件发生时间标注
let markLineData = [];
if (eventTime) {
const eventDate = moment(eventTime).format('YYYY-MM-DD');
const idx = dates.indexOf(eventDate);
if (idx !== -1) {
markLineData = [{
name: '事件发生',
xAxis: idx,
label: {
formatter: '事件发生',
position: 'middle',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else if (eventDate < dates[0]) {
markLineData = [{
name: '事件发生',
xAxis: 0,
label: {
formatter: '事件发生\n(历史)',
position: 'start',
offset: [10, 0],
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else if (eventDate > dates[dates.length - 1]) {
markLineData = [{
name: '事件发生',
xAxis: dates.length - 1,
label: {
formatter: '事件发生\n(待反映)',
position: 'end',
offset: [-10, 0],
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
}
}
return {
title: {
text: `${stock.name} (${stock.code}) 日K线图`,
left: 'center',
top: 10
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: (params) => {
if (!params || params.length === 0) return '';
const data = params[0].data;
const date = params[0].name;
return `
${date}<br/>
开盘: ${data[0]}<br/>
收盘: ${data[1]}<br/>
最低: ${data[2]}<br/>
最高: ${data[3]}<br/>
成交量: ${(data[4] / 10000).toFixed(2)}万手<br/>
涨跌幅: ${((data[1] - data[0]) / data[0] * 100).toFixed(2)}%
`;
}
},
legend: {
data: ['K线', '成交量'],
top: 40
},
grid: [
{
left: '10%',
right: '10%',
top: '15%',
height: '50%'
},
{
left: '10%',
right: '10%',
top: '70%',
height: '15%'
}
],
xAxis: [
{
type: 'category',
data: dates,
scale: true,
boundaryGap: false,
axisLine: { onZero: false },
splitLine: { show: false },
min: 'dataMin',
max: 'dataMax'
},
{
type: 'category',
gridIndex: 1,
data: dates,
scale: true,
boundaryGap: false,
axisLine: { onZero: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
min: 'dataMin',
max: 'dataMax'
}
],
yAxis: [
{
scale: true,
splitArea: {
show: true
}
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false }
}
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 70,
end: 100
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
bottom: 10,
start: 70,
end: 100
}
],
series: [
{
name: 'K线',
type: 'candlestick',
data: data,
itemStyle: {
color: '#ef5350',
color0: '#26a69a',
borderColor: '#ef5350',
borderColor0: '#26a69a'
},
markLine: markLineData.length ? {
symbol: ['circle', 'none'],
symbolSize: [8, 8],
data: markLineData,
animation: false
} : undefined
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes
}
]
};
};
// 获取分时图配置
const getTimelineOption = () => {
if (!klineData || !klineData.data || klineData.data.length === 0) {
return {};
}
const times = klineData.data.map(item => item[0]);
const prices = klineData.data.map(item => item[1]);
const volumes = klineData.data.map(item => item[2]);
const avgPrice = klineData.avgPrice || prices[0];
// 事件发生时间标注
let markLineData = [];
if (eventTime) {
const eventDateTime = moment(eventTime);
const eventDate = eventDateTime.format('YYYY-MM-DD');
const eventHourMin = eventDateTime.format('HH:mm');
if (eventDate === klineData.trade_date) {
if (eventHourMin < times[0]) {
markLineData = [{
name: '事件发生',
xAxis: 0,
label: {
formatter: '事件发生\n(盘前)',
position: 'start',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else if (eventHourMin > times[times.length - 1]) {
markLineData = [{
name: '事件发生',
xAxis: times.length - 1,
label: {
formatter: '事件发生\n(盘后)',
position: 'end',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
} else {
const nearestIdx = times.findIndex(time => time >= eventHourMin);
markLineData = [{
name: '事件发生',
xAxis: nearestIdx,
label: {
formatter: '事件发生',
position: 'middle',
color: '#ffa500',
fontSize: 12
},
lineStyle: {
color: '#ffa500',
type: 'solid',
width: 2
}
}];
}
}
}
return {
title: {
text: `${stock.name} (${stock.code}) 分时图`,
left: 'center',
top: 10
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['价格', '均价', '成交量'],
top: 40
},
grid: [
{
left: '10%',
right: '10%',
top: '15%',
height: '50%'
},
{
left: '10%',
right: '10%',
top: '70%',
height: '15%'
}
],
xAxis: [
{
type: 'category',
data: times,
boundaryGap: false,
axisLine: { onZero: false }
},
{
type: 'category',
gridIndex: 1,
data: times,
boundaryGap: false,
axisLine: { onZero: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false }
}
],
yAxis: [
{
scale: true,
splitArea: {
show: true
}
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false }
}
],
series: [
{
name: '价格',
type: 'line',
data: prices,
smooth: true,
symbol: 'none',
lineStyle: {
width: 2
},
markLine: markLineData.length ? {
symbol: ['circle', 'none'],
symbolSize: [8, 8],
data: markLineData,
animation: false
} : undefined
},
{
name: '均价',
type: 'line',
data: new Array(times.length).fill(avgPrice),
smooth: true,
symbol: 'none',
lineStyle: {
width: 1,
type: 'dashed'
},
itemStyle: {
color: '#ff9800'
}
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: '#1890ff'
}
}
]
};
};
return (
<Modal
title={`${stock?.name} (${stock?.code}) - 行情走势`}
open={visible}
onCancel={onClose}
width={1000}
footer={null}
destroyOnClose
>
<div style={{ marginBottom: 16, textAlign: 'center' }}>
<Radio.Group
value={chartType}
onChange={(e) => setChartType(e.target.value)}
buttonStyle="solid"
>
<Radio.Button value="kline">日K线</Radio.Button>
<Radio.Button value="timeline">分时图</Radio.Button>
</Radio.Group>
</div>
<Spin spinning={loading}>
<div style={{ height: 500 }}>
{klineData && (
<ReactECharts
option={chartType === 'kline' ? getKlineOption() : getTimelineOption()}
style={{ height: '100%', width: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
)}
</div>
</Spin>
</Modal>
);
};
export default StockKlineModal;

File diff suppressed because it is too large Load Diff

View File

@@ -1,159 +1,108 @@
// 简易版公司盈利预测报表视图
import React, { useState, useEffect } from 'react';
import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack } from '@chakra-ui/react';
import React, { useState } from 'react';
import { Box, Flex, Input, Button, SimpleGrid } from '@chakra-ui/react';
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { stockService } from '../../services/eventService';
const ForecastReport = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const ForecastReport = () => {
const [code, setCode] = useState('600000');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) setData(resp.data);
} finally {
setLoading(false);
}
};
const load = async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) setData(resp.data);
} finally {
setLoading(false);
}
};
// 监听props中的stockCode变化
useEffect(() => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode]);
const years = data?.detail_table?.years || [];
// 加载数据
useEffect(() => {
if (code) {
load();
}
}, [code]);
const incomeProfitOption = data ? {
tooltip: { trigger: 'axis' },
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
xAxis: { type: 'category', data: data.income_profit_trend.years },
yAxis: [
{ type: 'value', name: '收入(百万元)' },
{ type: 'value', name: '利润(百万元)' }
],
series: [
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true },
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true }
]
} : {};
const years = data?.detail_table?.years || [];
const growthOption = data ? {
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: data.growth_bars.years },
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
series: [ { name: '营收增长率(%)', type: 'bar', data: data.growth_bars.revenue_growth_pct } ]
} : {};
const colors = ['#805AD5', '#38B2AC', '#F6AD55', '#63B3ED', '#E53E3E', '#10B981'];
const epsOption = data ? {
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: data.eps_trend.years },
yAxis: { type: 'value', name: '元/股' },
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true } ]
} : {};
const incomeProfitOption = data ? {
color: [colors[0], colors[4]],
tooltip: { trigger: 'axis' },
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.income_profit_trend.years, axisLabel: { rotate: 30 } },
yAxis: [
{ type: 'value', name: '收入(百万元)' },
{ type: 'value', name: '利润(百万元)' }
],
series: [
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.08 } },
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true, lineStyle: { width: 2 } }
]
} : {};
const pePegOption = data ? {
tooltip: { trigger: 'axis' },
legend: { data: ['PE', 'PEG'] },
xAxis: { type: 'category', data: data.pe_peg_axes.years },
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
series: [
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
]
} : {};
const growthOption = data ? {
color: [colors[2]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.growth_bars.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
series: [ {
name: '营收增长率(%)',
type: 'bar',
data: data.growth_bars.revenue_growth_pct,
itemStyle: { color: (params) => params.value >= 0 ? '#E53E3E' : '#10B981' }
} ]
} : {};
return (
<Box p={4}>
<Flex gap={2} mb={4}>
<Input placeholder="输入股票代码,如 600000" value={code} onChange={(e) => setCode(e.target.value)} />
<Button colorScheme="purple" isLoading={loading} onClick={load}>生成报表</Button>
</Flex>
const epsOption = data ? {
color: [colors[3]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.eps_trend.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', name: '元/股' },
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true, areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 } } ]
} : {};
{data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }}/></CardBody></Card>
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }}/></CardBody></Card>
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }}/></CardBody></Card>
<Card><CardHeader><Heading size="sm">PE PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }}/></CardBody></Card>
</SimpleGrid>
)}
const pePegOption = data ? {
color: [colors[0], colors[1]],
tooltip: { trigger: 'axis' },
legend: { data: ['PE', 'PEG'] },
grid: { left: 40, right: 40, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.pe_peg_axes.years, axisLabel: { rotate: 30 } },
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
series: [
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
]
} : {};
return (
<Box p={4}>
<HStack align="center" justify="space-between" mb={4}>
<Heading size="md">盈利预测报表</Heading>
<Button
leftIcon={<RepeatIcon />}
size="sm"
variant="outline"
onClick={load}
isLoading={loading}
>
刷新数据
</Button>
</HStack>
{loading && !data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1,2,3,4].map(i => (
<Card key={i}>
<CardHeader><Skeleton height="18px" width="140px" /></CardHeader>
<CardBody>
<Skeleton height="320px" />
</CardBody>
</Card>
))}
</SimpleGrid>
)}
{data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">PE PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }} /></CardBody></Card>
</SimpleGrid>
)}
{data && (
<Card mt={4}>
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
<CardBody>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>关键指标</Th>
{years.map(y => <Th key={y}>{y}</Th>)}
</Tr>
</Thead>
<Tbody>
{data.detail_table.rows.map((row, idx) => (
<Tr key={idx}>
<Td><Tag>{row['指标']}</Tag></Td>
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
)}
</Box>
);
{data && (
<Card mt={4}>
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
<CardBody>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>关键指标</Th>
{years.map(y => <Th key={y}>{y}</Th>)}
</Tr>
</Thead>
<Tbody>
{data.detail_table.rows.map((row, idx) => (
<Tr key={idx}>
<Td><Tag>{row['指标']}</Tag></Td>
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
)}
</Box>
);
};
export default ForecastReport;

View File

@@ -122,13 +122,14 @@ const ConceptTimelineModal = ({
})
);
// 获取新闻(精确匹配最近100天最多100条
// 获取新闻(使用与原代码相同的参数
const newsParams = new URLSearchParams({
query: conceptName,
exact_match: 1,
start_date: startDateStr,
end_date: endDateStr,
top_k: 100
top_k: 100, // 与原代码一致
pagenum: 1, // 第一页
pagesize: 100 // 一次获取100条避免分页
});
const newsUrl = `${NEWS_API_URL}/search_china_news?${newsParams}`;
@@ -149,13 +150,14 @@ const ConceptTimelineModal = ({
})
);
// 获取研报(文本模式、精确匹配最近100天最多30条
// 获取研报(使用与原代码相同的参数结构
const reportParams = new URLSearchParams({
query: conceptName,
mode: 'text',
exact_match: 1,
size: 30,
start_date: startDateStr
start_date: startDateStr,
end_date: endDateStr,
size: 100, // 一次获取100条
from: 0, // 从第0条开始
mode: 'hybrid' // 与原代码一致
});
const reportUrl = `${REPORT_API_URL}/search?${reportParams}`;
@@ -233,41 +235,34 @@ const ConceptTimelineModal = ({
});
}
// 处理研报(按时间降序排序,最新的在前),兼容 data.results 与 results
if (reportResult) {
const reports = (reportResult.data && Array.isArray(reportResult.data.results))
? reportResult.data.results
: (Array.isArray(reportResult.results) ? reportResult.results : []);
// 处理研报(按时间降序排序,最新的在前)
if (reportResult && reportResult.results && Array.isArray(reportResult.results)) {
// 先排序
const sortedReports = reportResult.results.sort((a, b) => {
const dateA = new Date(a.declare_date || 0);
const dateB = new Date(b.declare_date || 0);
return dateB - dateA; // 降序
});
if (reports.length > 0) {
const sortedReports = reports.sort((a, b) => {
const dateA = new Date((a.declare_date || '').replace(' ', 'T'));
const dateB = new Date((b.declare_date || '').replace(' ', 'T'));
return dateB - dateA; // 降序
});
sortedReports.forEach(report => {
if (report.declare_date) {
// 研报日期格式已经是 YYYY-MM-DD
const dateOnly = report.declare_date;
sortedReports.forEach(report => {
const rawDate = report.declare_date || '';
if (rawDate) {
const dateOnly = rawDate.includes('T') ? rawDate.split('T')[0]
: rawDate.includes(' ') ? rawDate.split(' ')[0]
: rawDate;
events.push({
type: 'report',
date: dateOnly,
time: rawDate,
title: report.report_title,
content: report.content,
publisher: report.publisher,
author: report.author,
rating: report.rating,
security_name: report.security_name,
content_url: report.content_url
});
}
});
}
events.push({
type: 'report',
date: dateOnly, // 日期
time: report.declare_date, // 研报没有具体时间
title: report.report_title,
content: report.content,
publisher: report.publisher,
author: report.author,
rating: report.rating,
security_name: report.security_name,
content_url: report.content_url
});
}
});
}
// 按日期分组
@@ -326,17 +321,18 @@ const ConceptTimelineModal = ({
}));
};
// 格式化日期显示(包含年份)
// 格式化日期显示
const formatDateDisplay = (dateStr) => {
const date = new Date(dateStr);
const today = new Date();
const diffTime = today - date;
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const formatted = `${year}-${month}-${day}`;
const formatted = date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
weekday: 'short'
});
if (diffDays === 0) return `今天 ${formatted}`;
if (diffDays === 1) return `昨天 ${formatted}`;
@@ -346,20 +342,6 @@ const ConceptTimelineModal = ({
return formatted;
};
// 格式化完整时间YYYY-MM-DD HH:mm
const formatDateTime = (dateTimeStr) => {
if (!dateTimeStr) return '-';
const normalized = typeof dateTimeStr === 'string' ? dateTimeStr.replace(' ', 'T') : dateTimeStr;
const dt = new Date(normalized);
if (isNaN(dt.getTime())) return '-';
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, '0');
const d = String(dt.getDate()).padStart(2, '0');
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}`;
};
// 获取涨跌幅颜色和图标
const getPriceInfo = (price) => {
if (!price || price.avg_change_pct === null) {
@@ -411,33 +393,11 @@ const ConceptTimelineModal = ({
<Badge colorScheme="yellow" ml={2}>
最近100天
</Badge>
<Badge colorScheme="purple" ml={2} fontSize="xs">
🔥 Max版功能
</Badge>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody
py={6}
bg="gray.50"
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#a8a8a8',
},
}}
>
<ModalBody py={6} bg="gray.50">
{loading ? (
<Center py={20}>
<VStack spacing={4}>
@@ -654,22 +614,6 @@ const ConceptTimelineModal = ({
boxShadow="sm"
border="1px solid"
borderColor="gray.200"
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#a8a8a8',
},
}}
>
{item.events.map((event, eventIdx) => (
<Box
@@ -723,38 +667,17 @@ const ConceptTimelineModal = ({
{event.content || '暂无内容'}
</Text>
<Button
size="xs"
variant="link"
colorScheme="blue"
leftIcon={<ViewIcon />}
onClick={() => {
if (event.type === 'news') {
setSelectedNews({
title: event.title,
content: event.content,
source: event.source,
time: event.time,
url: event.url
});
setIsNewsModalOpen(true);
} else if (event.type === 'report') {
setSelectedReport({
title: event.title,
content: event.content,
publisher: event.publisher,
author: event.author,
time: event.time,
rating: event.rating,
security_name: event.security_name,
content_url: event.content_url
});
setIsReportModalOpen(true);
}
}}
>
查看详情
</Button>
{event.url && (
<Button
size="xs"
variant="link"
colorScheme="blue"
rightIcon={<ExternalLinkIcon />}
onClick={() => window.open(event.url, '_blank')}
>
查看原文
</Button>
)}
</VStack>
</Box>
))}
@@ -834,7 +757,7 @@ const ConceptTimelineModal = ({
<Text>{selectedReport.author}</Text>
)}
{selectedReport?.time && (
<Text>{formatDateTime(selectedReport.time)}</Text>
<Text>{selectedReport.time}</Text>
)}
{selectedReport?.rating && (
<Badge colorScheme="orange" variant="solid">
@@ -912,8 +835,17 @@ const ConceptTimelineModal = ({
{selectedNews.source === 'zsxq' ? '知识星球' : selectedNews.source}
</Badge>
)}
{selectedNews?.time && (
<Text>{formatDateTime(selectedNews.time)}</Text>
{selectedNews?.time && selectedNews.time.includes('T') && (
<Text>
{new Date(selectedNews.time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</Text>
)}
</HStack>
</VStack>

View File

@@ -78,18 +78,12 @@ import {
Collapse,
} from '@chakra-ui/react';
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress } from 'react-icons/fa';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal';
import ConceptStatsPanel from './components/ConceptStatsPanel';
// 导入导航栏组件
import HomeNavbar from '../../components/Navbars/HomeNavbar';
// 导入订阅权限管理
import { useSubscription } from '../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
// 导入市场服务
import { marketService } from '../../services/marketService';
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '/concept-api'
@@ -129,11 +123,6 @@ const ConceptCenter = () => {
const navigate = useNavigate();
const toast = useToast();
// 订阅权限管理
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [upgradeFeature, setUpgradeFeature] = useState('pro');
// 状态管理
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(false);
@@ -156,9 +145,6 @@ const ConceptCenter = () => {
const [selectedConceptName, setSelectedConceptName] = useState('');
const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false);
const [selectedConceptId, setSelectedConceptId] = useState('');
// 股票行情数据状态
const [stockMarketData, setStockMarketData] = useState({});
const [loadingStockData, setLoadingStockData] = useState(false);
// 默认图片路径
const defaultImage = '/assets/img/default-event.jpg';
@@ -180,18 +166,9 @@ const ConceptCenter = () => {
}
return null;
}, []);
// 打开内容模态框(新闻和研报)- 需要Max版权限
// 打开内容模态框(新闻和研报)
const handleViewContent = (e, conceptName, conceptId) => {
e.stopPropagation();
// 检查历史时间轴权限
if (!hasFeatureAccess('concept_timeline')) {
const recommendation = getUpgradeRecommendation('concept_timeline');
setUpgradeFeature(recommendation?.required || 'max');
setUpgradeModalOpen(true);
return;
}
setSelectedConceptForContent(conceptName);
setSelectedConceptId(conceptId);
setIsTimelineModalOpen(true);
@@ -370,78 +347,16 @@ const ConceptCenter = () => {
// 处理概念点击
const handleConceptClick = (conceptId, conceptName) => {
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(conceptName)}.html`;
const htmlPath = `/htmls/${conceptName}.html`;
window.open(htmlPath, '_blank');
};
// 获取股票行情数据
const fetchStockMarketData = async (stocks) => {
if (!stocks || stocks.length === 0) return;
setLoadingStockData(true);
const newMarketData = {};
try {
// 批量获取股票数据每次处理5个股票以避免并发过多
const batchSize = 5;
for (let i = 0; i < stocks.length; i += batchSize) {
const batch = stocks.slice(i, i + batchSize);
const promises = batch.map(async (stock) => {
if (!stock.stock_code) return null;
// 提取6位股票代码去掉交易所后缀
const seccode = stock.stock_code.substring(0, 6);
try {
const response = await marketService.getTradeData(seccode, 1);
if (response.success && response.data && response.data.length > 0) {
const latestData = response.data[response.data.length - 1];
return {
stock_code: stock.stock_code,
...latestData
};
}
} catch (error) {
console.warn(`获取股票 ${seccode} 行情数据失败:`, error);
}
return null;
});
const batchResults = await Promise.all(promises);
batchResults.forEach(result => {
if (result) {
newMarketData[result.stock_code] = result;
}
});
}
setStockMarketData(newMarketData);
} catch (error) {
console.error('批量获取股票行情数据失败:', error);
} finally {
setLoadingStockData(false);
}
};
// 打开股票详情Modal - 需要Pro版权限
// 打开股票详情Modal
const handleViewStocks = (e, concept) => {
e.stopPropagation();
// 检查热门个股权限
if (!hasFeatureAccess('hot_stocks')) {
const recommendation = getUpgradeRecommendation('hot_stocks');
setUpgradeFeature(recommendation?.required || 'pro');
setUpgradeModalOpen(true);
return;
}
setSelectedConceptStocks(concept.stocks || []);
setSelectedConceptName(concept.concept);
setStockMarketData({}); // 清空之前的数据
setIsStockModalOpen(true);
// 获取股票行情数据
fetchStockMarketData(concept.stocks || []);
};
// 格式化涨跌幅显示
@@ -457,33 +372,6 @@ const ConceptCenter = () => {
return value > 0 ? 'red' : value < 0 ? 'green' : 'gray';
};
// 格式化价格显示
const formatPrice = (value) => {
if (value === null || value === undefined) return '-';
return `¥${value.toFixed(2)}`;
};
// 格式化涨跌幅显示(股票表格专用)
const formatStockChangePercent = (value) => {
if (value === null || value === undefined) return '-';
const formatted = value.toFixed(2);
return value >= 0 ? `+${formatted}%` : `${formatted}%`;
};
// 获取涨跌幅颜色(股票表格专用)
const getStockChangeColor = (value) => {
if (value === null || value === undefined) return 'gray';
return value > 0 ? 'red' : value < 0 ? 'green' : 'gray';
};
// 生成公司详情链接
const generateCompanyLink = (stockCode) => {
if (!stockCode) return '#';
// 提取6位股票代码
const seccode = stockCode.substring(0, 6);
return `https://valuefrontier.cn/company?scode=${seccode}`;
};
// 渲染动态表格列
const renderStockTable = () => {
if (!selectedConceptStocks || selectedConceptStocks.length === 0) {
@@ -495,8 +383,7 @@ const ConceptCenter = () => {
Object.keys(stock).forEach(key => allFields.add(key));
});
// 定义固定的列顺序,包含新增的现价和涨跌幅列
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
const orderedFields = ['stock_name', 'stock_code'];
allFields.forEach(field => {
if (!orderedFields.includes(field)) {
orderedFields.push(field);
@@ -504,86 +391,31 @@ const ConceptCenter = () => {
});
return (
<Box>
{loadingStockData && (
<Box mb={4} textAlign="center">
<HStack justify="center" spacing={2}>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="gray.600">正在获取行情数据...</Text>
</HStack>
</Box>
)}
<TableContainer maxH="60vh" overflowY="auto">
<Table variant="simple" size="sm">
<Thead position="sticky" top={0} bg="white" zIndex={1}>
<Tr>
<TableContainer maxH="60vh" overflowY="auto">
<Table variant="simple" size="sm">
<Thead position="sticky" top={0} bg="white" zIndex={1}>
<Tr>
{orderedFields.map(field => (
<Th key={field}>
{field === 'stock_name' ? '股票名称' :
field === 'stock_code' ? '股票代码' : field}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{selectedConceptStocks.map((stock, idx) => (
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
{orderedFields.map(field => (
<Th key={field}>
{field === 'stock_name' ? '股票名称' :
field === 'stock_code' ? '股票代码' :
field === 'current_price' ? '现价' :
field === 'change_percent' ? '当日涨跌幅' : field}
</Th>
<Td key={field}>
{stock[field] || '-'}
</Td>
))}
</Tr>
</Thead>
<Tbody>
{selectedConceptStocks.map((stock, idx) => {
const marketData = stockMarketData[stock.stock_code];
const companyLink = generateCompanyLink(stock.stock_code);
return (
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
{orderedFields.map(field => {
let cellContent = stock[field] || '-';
let cellProps = {};
// 处理特殊字段
if (field === 'current_price') {
cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? <Spinner size="xs" /> : '-');
} else if (field === 'change_percent') {
if (marketData) {
cellContent = formatStockChangePercent(marketData.change_percent);
cellProps.color = `${getStockChangeColor(marketData.change_percent)}.500`;
cellProps.fontWeight = 'bold';
} else {
cellContent = loadingStockData ? <Spinner size="xs" /> : '-';
}
} else if (field === 'stock_name' || field === 'stock_code') {
// 添加超链接
cellContent = (
<Text
as="a"
href={companyLink}
target="_blank"
rel="noopener noreferrer"
color="blue.600"
textDecoration="underline"
_hover={{
color: 'blue.800',
textDecoration: 'underline'
}}
cursor="pointer"
>
{stock[field] || '-'}
</Text>
);
}
return (
<Td key={field} {...cellProps}>
{cellContent}
</Td>
);
})}
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</Box>
))}
</Tbody>
</Table>
</TableContainer>
);
};
@@ -776,45 +608,23 @@ const ConceptCenter = () => {
>
<Flex align="center" justify="space-between">
<Box flex={1}>
<HStack spacing={2} mb={1}>
<Text fontSize="xs" color="gray.600" fontWeight="medium">
热门个股
</Text>
{!hasFeatureAccess('hot_stocks') && (
<Badge colorScheme="yellow" size="sm">
🔒需Pro
</Badge>
)}
</HStack>
<Text fontSize="xs" color="gray.600" fontWeight="medium" mb={1}>
热门个股
</Text>
<HStack spacing={1} flexWrap="wrap">
{hasFeatureAccess('hot_stocks') ? (
<>
{concept.stocks.slice(0, 2).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
<TagLabel fontSize="xs">{stock.stock_name}</TagLabel>
</Tag>
))}
{concept.stocks.length > 2 && (
<Text fontSize="xs" color="purple.600" fontWeight="medium">
+{concept.stocks.length - 2}更多
</Text>
)}
</>
) : (
<HStack spacing={1}>
<Icon as={FaLock} boxSize="10px" color="yellow.600" />
<Text fontSize="xs" color="yellow.600" fontWeight="medium">
升级查看{concept.stocks.length}只个股
</Text>
</HStack>
{concept.stocks.slice(0, 2).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
<TagLabel fontSize="xs">{stock.stock_name}</TagLabel>
</Tag>
))}
{concept.stocks.length > 2 && (
<Text fontSize="xs" color="purple.600" fontWeight="medium">
+{concept.stocks.length - 2}更多
</Text>
)}
</HStack>
</Box>
<Icon
as={hasFeatureAccess('hot_stocks') ? ChevronRightIcon : FaLock}
color={hasFeatureAccess('hot_stocks') ? 'purple.500' : 'yellow.600'}
boxSize={4}
/>
<Icon as={ChevronRightIcon} color="purple.500" boxSize={4} />
</Flex>
</Box>
)}
@@ -858,173 +668,6 @@ const ConceptCenter = () => {
);
};
// 概念列表项组件 - 列表视图
const ConceptListItem = ({ concept }) => {
const changePercent = concept.price_info?.avg_change_pct;
const changeColor = getChangeColor(changePercent);
const hasChange = changePercent !== null && changePercent !== undefined;
return (
<Card
cursor="pointer"
onClick={() => handleConceptClick(concept.concept_id, concept.concept)}
bg="white"
borderWidth="1px"
borderColor="gray.200"
overflow="hidden"
_hover={{
transform: 'translateX(4px)',
boxShadow: 'lg',
borderColor: 'purple.300',
}}
transition="all 0.3s"
>
<CardBody p={6}>
<Flex align="center" gap={6}>
{/* 左侧图标区域 */}
<Box
width="80px"
height="80px"
borderRadius="xl"
bgGradient="linear(to-br, purple.100, pink.100)"
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
flexShrink={0}
>
<Icon as={FaTags} boxSize={8} color="purple.400" />
{hasChange && (
<Badge
position="absolute"
top={-2}
right={-2}
bg={changeColor === 'red' ? 'red.500' : changeColor === 'green' ? 'green.500' : 'gray.500'}
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="full"
fontWeight="bold"
minW="auto"
>
<Icon
as={changePercent > 0 ? FaArrowUp : changePercent < 0 ? FaArrowDown : null}
boxSize={2}
mr={1}
/>
{formatChangePercent(changePercent)}
</Badge>
)}
</Box>
{/* 中间内容区域 */}
<Box flex={1}>
<VStack align="start" spacing={3}>
<Heading size="md" color="gray.800" noOfLines={1}>
{concept.concept}
</Heading>
<Text color="gray.600" fontSize="sm" noOfLines={2}>
{concept.description || '该概念板块涵盖相关技术、产业链和市场应用等多个维度的投资机会'}
</Text>
<HStack spacing={4} flexWrap="wrap">
<HStack spacing={1}>
<Icon as={FaChartLine} boxSize={4} color="purple.500" />
<Text fontSize="sm" fontWeight="medium" color="gray.700">
{concept.stock_count || 0} 只股票
</Text>
</HStack>
{hasChange && concept.price_info?.trade_date && (
<HStack spacing={1}>
<Icon as={FaCalendarAlt} boxSize={4} color="blue.500" />
<Text fontSize="sm" color="gray.600">
{new Date(concept.price_info.trade_date).toLocaleDateString('zh-CN')}
</Text>
</HStack>
)}
{formatHappenedTimes(concept.happened_times)}
</HStack>
</VStack>
</Box>
{/* 右侧操作区域 */}
<VStack spacing={3} align="end" flexShrink={0}>
<HStack spacing={3}>
<Button
size="sm"
leftIcon={<ViewIcon />}
colorScheme="blue"
variant="outline"
onClick={(e) => handleViewStocks(e, concept)}
borderRadius="full"
>
查看个股
</Button>
<Button
size="sm"
leftIcon={<FaChartLine />}
colorScheme="purple"
variant="solid"
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
borderRadius="full"
>
历史时间轴
</Button>
</HStack>
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<HStack spacing={1} mb={2}>
<Text fontSize="xs" color="gray.500">热门个股</Text>
{!hasFeatureAccess('hot_stocks') && (
<Badge colorScheme="yellow" size="sm">
🔒需Pro
</Badge>
)}
</HStack>
<Wrap spacing={1} justify="end">
{hasFeatureAccess('hot_stocks') ? (
<>
{concept.stocks.slice(0, 3).map((stock, idx) => (
<WrapItem key={idx}>
<Tag size="sm" colorScheme="purple" variant="subtle">
<TagLabel fontSize="xs">{stock.stock_name}</TagLabel>
</Tag>
</WrapItem>
))}
{concept.stocks.length > 3 && (
<WrapItem>
<Text fontSize="xs" color="purple.600" fontWeight="medium">
+{concept.stocks.length - 3}更多
</Text>
</WrapItem>
)}
</>
) : (
<WrapItem>
<HStack spacing={1}>
<Icon as={FaLock} boxSize="8px" color="yellow.600" />
<Text fontSize="xs" color="yellow.600" fontWeight="medium">
升级查看{concept.stocks.length}
</Text>
</HStack>
</WrapItem>
)}
</Wrap>
</Box>
)}
</VStack>
</Flex>
</CardBody>
</Card>
);
};
// 骨架屏组件
const SkeletonCard = () => (
<Card bg="white" borderWidth="1px" borderColor="gray.200">
@@ -1125,38 +768,31 @@ const ConceptCenter = () => {
opacity={0.5}
/>
<Container maxW="container.xl" position="relative" py={{ base: 12, md: 20 }}>
<VStack spacing={8}>
<VStack spacing={4} textAlign="center">
<Container maxW="container.xl" position="relative" py={{ base: 20, md: 32 }}>
<VStack spacing={12}>
<VStack spacing={6} textAlign="center">
<HStack spacing={4} justify="center">
<Icon as={FaBrain} boxSize={16} color="yellow.300" />
</HStack>
<VStack spacing={2}>
<VStack spacing={3}>
<Heading
as="h1"
size="2xl"
size="3xl"
fontWeight="black"
bgGradient="linear(to-r, white, yellow.200)"
bgClip="text"
letterSpacing="tight"
lineHeight="shorter"
>
概念中心
</Heading>
<HStack spacing={2} justify="center">
<Icon as={FaClock} boxSize={4} color="yellow.200" />
<Text fontSize="sm" fontWeight="medium" opacity={0.95}>
约下午4点更新
</Text>
</HStack>
<Text fontSize="2xl" fontWeight="medium" opacity={0.95}>
大模型辅助的信息整理与呈现平台
AI驱动的概念板块智能分析平台
</Text>
<Text fontSize="lg" opacity={0.8} maxW="3xl" lineHeight="tall">
以大模型协助汇聚与清洗多源信息结合自主训练的领域知识图谱
基于深度学习算法实时监控市场动态精准捕捉概念热点
<br />
并由资深分析师进行人工整合与校准提供结构化参考信息
为您的投资决策提供全方位的数据支持和智能分析
</Text>
</VStack>
</VStack>
@@ -1190,7 +826,7 @@ const ConceptCenter = () => {
<Icon as={FaRocket} boxSize={8} color="cyan.300" />
<Text fontWeight="bold" fontSize="lg">智能追踪</Text>
<Text fontSize="sm" opacity={0.8} textAlign="center">
算法智能追踪
AI算法精准定位
</Text>
</VStack>
@@ -1229,11 +865,11 @@ const ConceptCenter = () => {
<HStack spacing={8} divider={<Divider orientation="vertical" height="40px" borderColor="whiteAlpha.400" />}>
<VStack>
<Text fontSize="3xl" fontWeight="bold" color="yellow.300">500+</Text>
<Text fontSize="3xl" fontWeight="bold" color="yellow.300">{totalConcepts}</Text>
<Text fontSize="sm" opacity={0.8}>概念板块</Text>
</VStack>
<VStack>
<Text fontSize="3xl" fontWeight="bold" color="cyan.300">5000+</Text>
<Text fontSize="3xl" fontWeight="bold" color="cyan.300">10K+</Text>
<Text fontSize="sm" opacity={0.8}>相关个股</Text>
</VStack>
<VStack>
@@ -1323,199 +959,146 @@ const ConceptCenter = () => {
<DateSelector />
</Box>
{/* 双栏布局:左侧概念卡片,右侧统计面板 */}
<Flex gap={8} direction={{ base: 'column', xl: 'row' }}>
{/* 左侧概念卡片区域 */}
<Box flex={1}>
<Card mb={8} shadow="sm">
<CardBody>
<Flex
direction={{ base: 'column', md: 'row' }}
justify="space-between"
align={{ base: 'stretch', md: 'center' }}
gap={4}
<Card mb={8} shadow="sm">
<CardBody>
<Flex
direction={{ base: 'column', md: 'row' }}
justify="space-between"
align={{ base: 'stretch', md: 'center' }}
gap={4}
>
<HStack spacing={4} align="center">
<Text fontWeight="medium" color="gray.700">排序方式</Text>
<Select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value)}
width="200px"
focusBorderColor="purple.500"
>
<HStack spacing={4} align="center">
<Text fontWeight="medium" color="gray.700">排序方式</Text>
<Select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value)}
width="200px"
focusBorderColor="purple.500"
>
<option value="change_pct">涨跌幅</option>
<option value="_score">相关度</option>
<option value="stock_count">股票数量</option>
<option value="concept_name">概念名称</option>
</Select>
{searchQuery && sortBy === '_score' && (
<Tooltip label="搜索时自动切换到相关度排序,以显示最匹配的结果。您也可以手动切换其他排序方式。">
<HStack spacing={1}>
<Icon as={InfoIcon} color="blue.500" boxSize={4} />
<Text fontSize="sm" color="blue.600">
智能排序
</Text>
</HStack>
</Tooltip>
)}
</HStack>
<ButtonGroup size="sm" isAttached variant="outline">
<IconButton
icon={<FaThLarge />}
onClick={() => setViewMode('grid')}
bg={viewMode === 'grid' ? 'purple.500' : 'transparent'}
color={viewMode === 'grid' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{ bg: viewMode === 'grid' ? 'purple.600' : 'purple.50' }}
aria-label="网格视图"
/>
<IconButton
icon={<FaList />}
onClick={() => setViewMode('list')}
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
color={viewMode === 'list' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{ bg: viewMode === 'list' ? 'purple.600' : 'purple.50' }}
aria-label="列表视图"
/>
</ButtonGroup>
</Flex>
</CardBody>
</Card>
{selectedDate && (
<Box mb={4} p={3} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
<HStack>
<Icon as={InfoIcon} color="blue.500" />
<Text fontSize="sm" color="blue.700">
当前显示 <strong>{selectedDate.toLocaleDateString('zh-CN')}</strong>
{searchQuery && <span>搜索词<strong>"{searchQuery}"</strong></span>}
</Text>
</HStack>
</Box>
)}
{loading ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{[...Array(12)].map((_, i) => (
<SkeletonCard key={i} />
))}
</SimpleGrid>
) : concepts.length > 0 ? (
<>
{viewMode === 'grid' ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} className="concept-grid">
{concepts.map((concept) => (
<Box key={concept.concept_id} className="concept-item" role="group">
<ConceptCard concept={concept} />
</Box>
))}
</SimpleGrid>
) : (
<VStack spacing={4} align="stretch" className="concept-list">
{concepts.map((concept) => (
<ConceptListItem key={concept.concept_id} concept={concept} />
))}
</VStack>
)}
<Center mt={12}>
<HStack spacing={2}>
<Button
size="sm"
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
isDisabled={currentPage === 1}
colorScheme="purple"
variant="outline"
>
上一页
</Button>
<HStack>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const pageNum = currentPage <= 3 ? i + 1 :
currentPage >= totalPages - 2 ? totalPages - 4 + i :
currentPage - 2 + i;
if (pageNum < 1 || pageNum > totalPages) return null;
return (
<Button
key={pageNum}
size="sm"
onClick={() => handlePageChange(pageNum)}
colorScheme="purple"
variant={pageNum === currentPage ? 'solid' : 'outline'}
>
{pageNum}
</Button>
);
})}
<option value="change_pct">涨跌幅</option>
<option value="_score">相关度</option>
<option value="stock_count">股票数量</option>
<option value="concept_name">概念名称</option>
</Select>
{searchQuery && sortBy === '_score' && (
<Tooltip label="搜索时自动切换到相关度排序,以显示最匹配的结果。您也可以手动切换其他排序方式。">
<HStack spacing={1}>
<Icon as={InfoIcon} color="blue.500" boxSize={4} />
<Text fontSize="sm" color="blue.600">
智能排序
</Text>
</HStack>
</Tooltip>
)}
</HStack>
<Button
size="sm"
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
isDisabled={currentPage === totalPages}
colorScheme="purple"
variant="outline"
>
下一页
</Button>
</HStack>
</Center>
</>
) : (
<Center h="400px">
<VStack spacing={6}>
<Icon as={FaTags} boxSize={20} color="gray.300" />
<VStack spacing={2}>
<Text fontSize="xl" color="gray.600" fontWeight="medium">暂无概念数据</Text>
<Text color="gray.500">请尝试其他搜索关键词或选择其他日期</Text>
</VStack>
</VStack>
</Center>
)}
</Box>
{/* 右侧统计面板 */}
<Box w={{ base: '100%', xl: '400px' }} flexShrink={0}>
<Box position="sticky" top={6}>
{hasFeatureAccess('concept_stats_panel') ? (
<ConceptStatsPanel
apiBaseUrl={API_BASE_URL}
onConceptClick={handleConceptClick}
<ButtonGroup size="sm" isAttached variant="outline">
<IconButton
icon={<FaThLarge />}
onClick={() => setViewMode('grid')}
bg={viewMode === 'grid' ? 'purple.500' : 'transparent'}
color={viewMode === 'grid' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{ bg: viewMode === 'grid' ? 'purple.600' : 'purple.50' }}
aria-label="网格视图"
/>
) : (
<Card>
<CardBody p={6}>
<VStack spacing={4} textAlign="center">
<Icon as={FaChartLine} boxSize={12} color="gray.300" />
<VStack spacing={2}>
<Heading size="md" color="gray.600">
概念统计中心
</Heading>
<Text fontSize="sm" color="gray.500">
此功能需要Pro版订阅才能使用
</Text>
</VStack>
<Button
colorScheme="blue"
leftIcon={<Icon as={FaRocket} />}
onClick={() => {
setUpgradeFeature('pro');
setUpgradeModalOpen(true);
}}
>
升级到Pro版
</Button>
</VStack>
</CardBody>
</Card>
)}
</Box>
<IconButton
icon={<FaList />}
onClick={() => setViewMode('list')}
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
color={viewMode === 'list' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{ bg: viewMode === 'list' ? 'purple.600' : 'purple.50' }}
aria-label="列表视图"
/>
</ButtonGroup>
</Flex>
</CardBody>
</Card>
{selectedDate && (
<Box mb={4} p={3} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
<HStack>
<Icon as={InfoIcon} color="blue.500" />
<Text fontSize="sm" color="blue.700">
当前显示 <strong>{selectedDate.toLocaleDateString('zh-CN')}</strong>
{searchQuery && <span>搜索词<strong>"{searchQuery}"</strong></span>}
</Text>
</HStack>
</Box>
</Flex>
)}
{loading ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={8}>
{[...Array(12)].map((_, i) => (
<SkeletonCard key={i} />
))}
</SimpleGrid>
) : concepts.length > 0 ? (
<>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={8} className="concept-grid">
{concepts.map((concept) => (
<Box key={concept.concept_id} className="concept-item" role="group">
<ConceptCard concept={concept} />
</Box>
))}
</SimpleGrid>
<Center mt={12}>
<HStack spacing={2}>
<Button
size="sm"
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
isDisabled={currentPage === 1}
colorScheme="purple"
variant="outline"
>
上一页
</Button>
<HStack>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const pageNum = currentPage <= 3 ? i + 1 :
currentPage >= totalPages - 2 ? totalPages - 4 + i :
currentPage - 2 + i;
if (pageNum < 1 || pageNum > totalPages) return null;
return (
<Button
key={pageNum}
size="sm"
onClick={() => handlePageChange(pageNum)}
colorScheme="purple"
variant={pageNum === currentPage ? 'solid' : 'outline'}
>
{pageNum}
</Button>
);
})}
</HStack>
<Button
size="sm"
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
isDisabled={currentPage === totalPages}
colorScheme="purple"
variant="outline"
>
下一页
</Button>
</HStack>
</Center>
</>
) : (
<Center h="400px">
<VStack spacing={6}>
<Icon as={FaTags} boxSize={20} color="gray.300" />
<VStack spacing={2}>
<Text fontSize="xl" color="gray.600" fontWeight="medium">暂无概念数据</Text>
<Text color="gray.500">请尝试其他搜索关键词或选择其他日期</Text>
</VStack>
</VStack>
</Center>
)}
</Container>
{/* 股票详情Modal */}
@@ -1552,16 +1135,6 @@ const ConceptCenter = () => {
conceptId={selectedConceptId}
/>
{/* 订阅升级Modal */}
<SubscriptionUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
requiredLevel={upgradeFeature}
featureName={
upgradeFeature === 'pro' ? '概念统计中心和热门个股' : '概念历史时间轴'
}
/>
</Box>
);
};

View File

@@ -33,8 +33,7 @@ import {
Tr,
Th,
Td,
TableContainer,
Link
TableContainer
} from '@chakra-ui/react';
import {
FaExclamationTriangle,
@@ -145,47 +144,6 @@ const HistoricalEvents = ({
return `${Math.floor(diffDays / 365)}年前`;
};
// 可展开的文本组件
const ExpandableText = ({ text, maxLength = 20 }) => {
const { isOpen, onToggle } = useDisclosure();
const [shouldTruncate, setShouldTruncate] = useState(false);
useEffect(() => {
if (text && text.length > maxLength) {
setShouldTruncate(true);
} else {
setShouldTruncate(false);
}
}, [text, maxLength]);
if (!text) return <Text fontSize="xs">--</Text>;
const displayText = shouldTruncate && !isOpen
? text.substring(0, maxLength) + '...'
: text;
return (
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={isOpen ? undefined : 2} maxW="300px">
{displayText}{text.includes('AI合成') ? '' : 'AI合成'}
</Text>
{shouldTruncate && (
<Button
size="xs"
variant="link"
color="blue.500"
onClick={onToggle}
height="auto"
py={0}
minH={0}
>
{isOpen ? '收起' : '展开'}
</Button>
)}
</VStack>
);
};
// 加载状态
if (loading) {
return (
@@ -248,7 +206,7 @@ const HistoricalEvents = ({
超预期得分: {expectationScore}
</Text>
<Text fontSize="xs" color="yellow.700">
基于历史事件判断当前事件的超预期情况满分100分AI合成
基于历史事件判断当前事件的超预期情况满分100分
</Text>
</VStack>
</HStack>
@@ -359,10 +317,10 @@ const HistoricalEvents = ({
</HStack>
</HStack>
{/* 事件简介 */}
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
{event.content ? `${event.content}AI合成` : '暂无内容'}
</Text>
{/* 事件简介 */}
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
{event.content || '暂无内容'}
</Text>
{/* 展开的详细信息 */}
<Collapse in={isExpanded} animateOpacity>
@@ -401,9 +359,9 @@ const HistoricalEvents = ({
</VStack>
{/* 事件相关股票模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent maxW="80vw" maxH="85vh">
<ModalContent>
<ModalHeader>
<VStack align="flex-start" spacing={1}>
<Text>{selectedEvent?.title || '历史事件'}</Text>
@@ -414,7 +372,7 @@ const HistoricalEvents = ({
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto" maxH="calc(85vh - 180px)">
<ModalBody>
{loadingStocks ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
@@ -423,7 +381,6 @@ const HistoricalEvents = ({
) : (
<StocksList
stocks={selectedEvent ? eventStocks[selectedEvent.id] || [] : []}
eventTradingDate={selectedEvent ? selectedEvent.event_date : null}
/>
)}
</ModalBody>
@@ -438,14 +395,8 @@ const HistoricalEvents = ({
};
// 股票列表子组件
const StocksList = ({ stocks, eventTradingDate }) => {
const StocksList = ({ stocks }) => {
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 处理股票代码,移除.SZ/.SH后缀
const formatStockCode = (stockCode) => {
if (!stockCode) return '';
return stockCode.replace(/\.(SZ|SH)$/i, '');
};
if (!stocks || stocks.length === 0) {
return (
@@ -458,81 +409,50 @@ const StocksList = ({ stocks, eventTradingDate }) => {
}
return (
<>
{eventTradingDate && (
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
📅 事件对应交易日{new Date(eventTradingDate).toLocaleDateString('zh-CN')}
</Text>
</Box>
)}
<TableContainer>
<Table size="md">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th>板块</Th>
<Th isNumeric>相关度</Th>
<Th isNumeric>事件日涨幅</Th>
<Th>关联原因</Th>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th>板块</Th>
<Th isNumeric>相关度</Th>
<Th>关联原因</Th>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, index) => (
<Tr key={stock.id || index}>
<Td fontFamily="mono" fontWeight="medium">
{stock.stock_code}
</Td>
<Td>{stock.stock_name || '--'}</Td>
<Td>
<Badge size="sm" variant="outline">
{stock.sector || '未知'}
</Badge>
</Td>
<Td isNumeric>
<Badge
colorScheme={
stock.correlation >= 0.8 ? 'red' :
stock.correlation >= 0.6 ? 'orange' : 'green'
}
size="sm"
>
{Math.round((stock.correlation || 0) * 100)}%
</Badge>
</Td>
<Td>
<Text fontSize="xs" noOfLines={2} maxW="200px">
{stock.relation_desc || '--'}
</Text>
</Td>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, index) => (
<Tr key={stock.id || index}>
<Td fontFamily="mono" fontWeight="medium">
<Link
href={`https://valuefrontier.cn/company?scode=${stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}`}
isExternal
color="blue.500"
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}
</Link>
</Td>
<Td>{stock.stock_name || '--'}</Td>
<Td>
<Badge size="sm" variant="outline">
{stock.sector || '未知'}
</Badge>
</Td>
<Td isNumeric>
<Badge
colorScheme={
stock.correlation >= 0.8 ? 'red' :
stock.correlation >= 0.6 ? 'orange' : 'green'
}
size="sm"
>
{Math.round((stock.correlation || 0) * 100)}%
</Badge>
</Td>
<Td isNumeric>
{stock.event_day_change_pct !== null && stock.event_day_change_pct !== undefined ? (
<Text
fontWeight="medium"
color={stock.event_day_change_pct >= 0 ? 'red.500' : 'green.500'}
>
{stock.event_day_change_pct >= 0 ? '+' : ''}{stock.event_day_change_pct.toFixed(2)}%
</Text>
) : (
<Text color={textSecondary} fontSize="sm">--</Text>
)}
</Td>
<Td>
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={2} maxW="300px">
{stock.relation_desc ? `${stock.relation_desc}AI合成` : '--'}
</Text>
</VStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</>
))}
</Tbody>
</Table>
</TableContainer>
);
};

View File

@@ -1,8 +1,7 @@
// src/views/EventDetail/components/RelatedConcepts.js - 支持概念API调用
// src/views/EventDetail/components/RelatedConcepts.js - 支持交易日计算
import React, { useState, useEffect } from 'react';
import {
Icon, // 明确导入 Icon 组件
Box,
VStack,
HStack,
@@ -26,40 +25,37 @@ import {
IconButton,
Tooltip,
Button,
Center,
Divider
Center
} from '@chakra-ui/react';
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment';
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
// API配置
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'https://valuefrontier.cn/concept-api';
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'http://111.198.58.126:16801';
// 增强版 ConceptCard 组件 - 展示更多数据细节
// ConceptCard 组件 - 修改为使用新的数据结构
const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const [imageError, setImageError] = useState(false);
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textColor = useColorModeValue('gray.600', 'gray.400');
const highlightBg = useColorModeValue('yellow.50', 'yellow.900');
// 计算涨跌幅颜色和符号
const changeColor = concept.price_info?.avg_change_pct > 0 ? 'red' : 'green';
// 计算涨跌幅颜色
const changeColor = concept.price_info?.avg_change_pct > 0 ? 'red.500' : 'green.500';
const changeSymbol = concept.price_info?.avg_change_pct > 0 ? '+' : '';
const hasValidPriceInfo = concept.price_info && concept.price_info.avg_change_pct !== null;
// 获取匹配类型的中文名称
const getMatchTypeName = (type) => {
const typeMap = {
'hybrid_knn': '混合匹配',
'keyword': '关键词匹配',
'semantic': '语义匹配'
};
return typeMap[type] || type;
const handleImageLoad = () => {
setImageLoading(false);
};
// 处理概念点击
const handleImageError = () => {
setImageLoading(false);
setImageError(true);
};
// 处理概念点击 - 跳转到概念详情页
const handleConceptClick = () => {
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(concept.concept)}.html`, '_blank');
};
@@ -68,196 +64,100 @@ const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
<Card
bg={cardBg}
borderColor={borderColor}
borderWidth={2}
cursor="pointer"
_hover={{
transform: 'translateY(-2px)',
shadow: 'xl',
borderColor: 'blue.400'
shadow: 'lg',
borderColor: 'blue.300'
}}
transition="all 0.3s"
transition="all 0.2s"
onClick={handleConceptClick}
>
<CardBody p={5}>
<VStack spacing={4} align="stretch">
{/* 头部信息 */}
<Box>
<HStack justify="space-between" align="flex-start" mb={2}>
<VStack align="start" spacing={1} flex={1}>
<Text fontSize="lg" fontWeight="bold" color="blue.600">
{concept.concept}
</Text>
<HStack spacing={2} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="xs">
相关度: {concept.score.toFixed(2)}
</Badge>
<Badge colorScheme="teal" fontSize="xs">
{getMatchTypeName(concept.match_type)}
</Badge>
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
</HStack>
</VStack>
{hasValidPriceInfo && (
<Box textAlign="right">
<Text fontSize="xs" color={textColor} mb={1}>
{tradingDate || concept.price_info.trade_date}
</Text>
<Badge
size="lg"
colorScheme={changeColor}
fontSize="md"
px={3}
py={1}
>
<CardBody p={4}>
<VStack spacing={3} align="stretch">
{/* 概念信息 */}
<VStack spacing={2} align="stretch">
<HStack justify="space-between" align="flex-start">
<Text fontSize="md" fontWeight="bold" noOfLines={2}>
{concept.concept}
</Text>
{hasValidPriceInfo ? (
<Tooltip label={`${tradingDate} 平均涨跌幅`}>
<Badge size="sm" colorScheme={concept.price_info.avg_change_pct > 0 ? 'red' : 'green'}>
{changeSymbol}{concept.price_info.avg_change_pct?.toFixed(2)}%
</Badge>
</Box>
</Tooltip>
) : (
<Tooltip label={tradingDate ? `${tradingDate} 暂无数据` : '暂无涨跌数据'}>
<Badge size="sm" variant="outline" colorScheme="gray">
--%
</Badge>
</Tooltip>
)}
</HStack>
</Box>
<Divider />
{/* 股票数量和相关度 */}
<HStack spacing={2}>
<Badge variant="subtle" colorScheme="blue">
{concept.stock_count} 只股票
</Badge>
<Badge variant="subtle" colorScheme="purple">
相关度: {(concept.score * 10).toFixed(1)}
</Badge>
</HStack>
{/* 概念描述 */}
<Box>
<Text
fontSize="sm"
color={textColor}
noOfLines={isExpanded ? undefined : 4}
lineHeight="1.6"
>
{concept.description}
</Text>
{concept.description && concept.description.length > 200 && (
<Button
size="xs"
variant="link"
colorScheme="blue"
mt={1}
{/* 概念描述 */}
{concept.description && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{concept.description}
</Text>
)}
{/* 部分股票展示 */}
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<Text fontSize="xs" color={textColor} mb={1}>
相关股票
</Text>
<HStack spacing={1} flexWrap="wrap">
{concept.stocks.slice(0, 3).map((stock, idx) => (
<Badge key={idx} size="sm" variant="outline">
{stock.stock_name}
</Badge>
))}
{concept.stocks.length > 3 && (
<Text fontSize="xs" color={textColor}>
{concept.stock_count}
</Text>
)}
</HStack>
</Box>
)}
<HStack justify="space-between" align="center" pt={2}>
<Text fontSize="xs" color={textColor}>
点击查看概念详情
</Text>
<IconButton
icon={<FaExternalLinkAlt />}
size="sm"
variant="ghost"
aria-label="查看详情"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
handleConceptClick();
}}
>
{isExpanded ? '收起' : '展开更多'}
</Button>
)}
</Box>
{/* 历史发生时间 */}
{concept.happened_times && concept.happened_times.length > 0 && (
<Box>
<Text fontSize="xs" fontWeight="semibold" mb={2} color={textColor}>
历史触发时间
</Text>
<HStack spacing={2} flexWrap="wrap">
{concept.happened_times.map((time, idx) => (
<Badge key={idx} variant="subtle" colorScheme="gray" fontSize="xs">
{time}
</Badge>
))}
</HStack>
</Box>
)}
{/* 相关股票展示 - 增强版 */}
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<HStack justify="space-between" mb={2}>
<Text fontSize="sm" fontWeight="semibold" color={textColor}>
核心相关股票
</Text>
<Text fontSize="xs" color="gray.500">
{concept.stock_count}
</Text>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
{concept.stocks.slice(0, isExpanded ? 8 : 4).map((stock, idx) => (
<Box
key={idx}
p={2}
borderRadius="md"
bg={useColorModeValue('gray.50', 'gray.700')}
fontSize="xs"
>
<HStack justify="space-between">
<Text fontWeight="semibold">
{stock.stock_name}
</Text>
<Badge size="sm" variant="outline">
{stock.stock_code}
</Badge>
</HStack>
{stock.reason && (
<Text fontSize="xs" color={textColor} mt={1} noOfLines={2}>
{stock.reason}
</Text>
)}
</Box>
))}
</SimpleGrid>
{concept.stocks.length > 4 && !isExpanded && (
<Button
size="xs"
variant="ghost"
colorScheme="blue"
mt={2}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
>
查看更多股票
</Button>
)}
</Box>
)}
{/* 操作按钮 */}
<HStack spacing={2} pt={2}>
<Button
size="sm"
colorScheme="blue"
leftIcon={<FaChartLine />}
flex={1}
onClick={(e) => {
e.stopPropagation();
handleConceptClick();
}}
>
查看概念详情
</Button>
<Button
size="sm"
variant="outline"
colorScheme="blue"
leftIcon={<FaEye />}
flex={1}
onClick={(e) => {
e.stopPropagation();
onViewDetails(concept);
}}
>
快速预览
</Button>
</HStack>
/>
</HStack>
</VStack>
</VStack>
</CardBody>
</Card>
);
};
// 主组件 - 修改为接收事件信息并调用API
// 主组件 - 修改为从父组件接收事件标题和时间并搜索概念
const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoading, error: externalError }) => {
// 调试:检查 Icon 组件是否可用
if (typeof Icon === 'undefined') {
console.error('Icon component is not defined! Make sure @chakra-ui/react is properly imported.');
return <div>组件加载错误Icon 组件未定义</div>;
}
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedConcept, setSelectedConcept] = useState(null);
const [concepts, setConcepts] = useState([]);
@@ -267,63 +167,37 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
const bgColor = useColorModeValue('blue.50', 'blue.900');
const textColor = useColorModeValue('gray.600', 'gray.400');
// 数据验证函数
const validateConceptData = (data) => {
if (!data || typeof data !== 'object') {
throw new Error('Invalid response data format');
// 计算有效交易日
useEffect(() => {
if (eventTime) {
const tradingDate = tradingDayUtils.getEffectiveTradingDay(eventTime);
setEffectiveTradingDate(tradingDate);
console.log('事件时间:', eventTime, '-> 有效交易日:', tradingDate);
}
// 验证新的API格式
if (data.results && Array.isArray(data.results)) {
return data.results.every(item =>
item &&
typeof item === 'object' &&
(item.concept || item.concept_id) &&
typeof item.score === 'number'
);
}
// 验证旧的API格式
if (data.data && data.data.concepts && Array.isArray(data.data.concepts)) {
return data.data.concepts.every(item =>
item &&
typeof item === 'object' &&
(item.concept || item.concept_id) &&
typeof item.score === 'number'
);
}
return false;
};
}, [eventTime]);
// 搜索概念函数
const searchConcepts = async (title, tradingDate) => {
if (!title) {
setConcepts([]);
return;
}
setLoading(true);
setError(null);
// 搜索相关概念
const searchConcepts = async (title, tradeDate) => {
try {
setLoading(true);
setError(null);
// 确保tradeDate是字符串格式
let formattedTradeDate;
if (typeof tradeDate === 'string') {
formattedTradeDate = tradeDate;
} else if (tradeDate instanceof Date) {
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD');
} else if (moment.isMoment(tradeDate)) {
formattedTradeDate = tradeDate.format('YYYY-MM-DD');
} else {
console.warn('Invalid tradeDate format:', tradeDate, typeof tradeDate);
formattedTradeDate = moment().format('YYYY-MM-DD');
}
const requestBody = {
query: title,
size: 4,
size: 4, // 返回前4个最相关的概念
page: 1,
sort_by: "_score",
trade_date: formattedTradeDate
sort_by: "_score" // 按相关度排序
};
console.log('Searching concepts with:', requestBody);
// 如果有交易日期,添加到请求中
if (tradingDate) {
requestBody.trade_date = tradingDate;
}
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
@@ -334,35 +208,18 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
throw new Error(`搜索失败: ${response.status}`);
}
const data = await response.json();
console.log('Concept search response:', data);
setConcepts(data.results || []);
// 数据验证
if (!validateConceptData(data)) {
console.warn('Invalid concept data format:', data);
setConcepts([]);
setError('返回的数据格式无效');
return;
}
// 修复适配实际的API响应格式
if (data.results && Array.isArray(data.results)) {
setConcepts(data.results);
// 使用传入的交易日期作为生效日期
setEffectiveTradingDate(formattedTradeDate);
} else if (data.data && data.data.concepts) {
// 保持向后兼容
setConcepts(data.data.concepts);
setEffectiveTradingDate(data.data.trade_date || formattedTradeDate);
} else {
setConcepts([]);
console.warn('No concepts found in response');
// 如果返回了价格日期,更新显示
if (data.price_date && data.price_date !== tradingDate) {
console.log('API返回的实际价格日期:', data.price_date);
}
} catch (err) {
console.error('Failed to search concepts:', err);
console.error('概念搜索错误:', err);
setError(err.message);
setConcepts([]);
} finally {
@@ -370,92 +227,23 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
}
};
// 当事件信息变化时调用API搜索概念
// 当事件标题或交易日变化时搜索概念
useEffect(() => {
if (eventTitle && eventTime) {
// 格式化日期为 YYYY-MM-DD
let formattedDate;
try {
// eventTime 可能是Date对象或字符串使用 moment 处理
let eventMoment;
// 检查是否是Date对象
if (eventTime instanceof Date) {
eventMoment = moment(eventTime);
} else if (typeof eventTime === 'string') {
eventMoment = moment(eventTime);
} else if (typeof eventTime === 'number') {
eventMoment = moment(eventTime);
} else {
console.warn('Unknown eventTime format:', eventTime, typeof eventTime);
eventMoment = moment();
}
// 确保moment对象有效
if (!eventMoment.isValid()) {
console.warn('Invalid eventTime:', eventTime);
eventMoment = moment();
}
formattedDate = eventMoment.format('YYYY-MM-DD');
// 如果时间是15:00之后获取下一个交易日
if (eventMoment.hour() >= 15) {
// 使用 tradingDayUtils 获取下一个交易日
if (tradingDayUtils && tradingDayUtils.getNextTradingDay) {
const nextTradingDay = tradingDayUtils.getNextTradingDay(formattedDate);
// 确保返回的是字符串格式
if (typeof nextTradingDay === 'string') {
formattedDate = nextTradingDay;
} else if (nextTradingDay instanceof Date) {
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD');
} else {
console.warn('tradingDayUtils.getNextTradingDay returned invalid format:', nextTradingDay);
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
}
} else {
// 降级处理:简单地加一天(不考虑周末和节假日)
console.warn('tradingDayUtils.getNextTradingDay not available, using simple date addition');
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
}
}
} catch (e) {
console.error('Failed to format event time:', e);
// 使用当前交易日作为fallback
if (tradingDayUtils && tradingDayUtils.getCurrentTradingDay) {
const currentTradingDay = tradingDayUtils.getCurrentTradingDay();
// 确保返回的是字符串格式
if (typeof currentTradingDay === 'string') {
formattedDate = currentTradingDay;
} else if (currentTradingDay instanceof Date) {
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD');
} else {
console.warn('tradingDayUtils.getCurrentTradingDay returned invalid format:', currentTradingDay);
formattedDate = moment().format('YYYY-MM-DD');
}
} else {
formattedDate = moment().format('YYYY-MM-DD');
}
}
searchConcepts(eventTitle, formattedDate);
} else if (!eventTitle) {
console.warn('No event title provided for concept search');
setConcepts([]);
if (eventTitle && effectiveTradingDate) {
searchConcepts(eventTitle, effectiveTradingDate);
} else if (eventTitle) {
// 如果没有交易日期,仍然搜索但不带日期参数
searchConcepts(eventTitle, null);
}
}, [eventTitle, eventTime]);
}, [eventTitle, effectiveTradingDate]);
const handleViewDetails = (concept) => {
setSelectedConcept(concept);
onOpen();
};
// 合并加载状态
const isLoading = externalLoading || loading;
const displayError = externalError || error;
// 加载状态
if (isLoading) {
if (loading || externalLoading) {
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1, 2, 3, 4].map((i) => (
@@ -468,11 +256,11 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
}
// 错误状态
if (displayError) {
if (error || externalError) {
return (
<Alert status="error" borderRadius="lg">
<AlertIcon />
加载相关概念失败: {displayError}
加载相关概念失败: {error || externalError}
</Alert>
);
}
@@ -481,9 +269,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (!concepts || concepts.length === 0) {
return (
<Box textAlign="center" py={8}>
<Text color="gray.500" mb={4}>
{eventTitle ? '未找到相关概念' : '暂无相关概念数据'}
</Text>
<Text color="gray.500" mb={4}>暂无相关概念数据</Text>
<Button
colorScheme="blue"
size="lg"
@@ -505,9 +291,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
<FaCalendarAlt color={textColor} />
<Text fontSize="sm" color={textColor}>
涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
{eventTime && effectiveTradingDate !== eventTime.split(' ')[0] && (
<Text as="span" ml={2} fontSize="xs">
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : eventTime}显示下一交易日数据)
(事件发生于 {eventTime}显示下一交易日数据)
</Text>
)}
</Text>
@@ -517,9 +303,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
{/* 概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
{concepts.map((concept) => (
<ConceptCard
key={concept.concept_id || index}
key={concept.concept_id}
concept={concept}
tradingDate={effectiveTradingDate}
onViewDetails={handleViewDetails}
@@ -555,183 +341,73 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
</VStack>
</Center>
{/* 增强版概念详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
{/* 概念详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader borderBottomWidth={1}>
<ModalContent>
<ModalHeader>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<Text fontSize="xl">{selectedConcept?.concept}</Text>
<HStack spacing={2}>
<Badge colorScheme="purple">
相关度: {selectedConcept?.score?.toFixed(2)}
</Badge>
<Badge colorScheme="teal">
{selectedConcept?.stock_count} 只股票
</Badge>
</HStack>
</VStack>
<Text>{selectedConcept?.concept}</Text>
{selectedConcept?.price_info && (
<VStack align="end" spacing={1}>
<Text fontSize="xs" color="gray.500">
{selectedConcept.price_info.trade_date || '暂无数据'}
</Text>
<Badge
size="lg"
colorScheme={selectedConcept.price_info.avg_change_pct > 0 ? 'red' : 'green'}
fontSize="lg"
px={4}
py={2}
>
{selectedConcept.price_info.avg_change_pct > 0 ? '+' : ''}
{selectedConcept.price_info.avg_change_pct?.toFixed(2) || '0.00'}%
</Badge>
</VStack>
<Badge colorScheme={selectedConcept.price_info.avg_change_pct > 0 ? 'red' : 'green'}>
{selectedConcept.price_info.avg_change_pct > 0 ? '+' : ''}
{selectedConcept.price_info.avg_change_pct?.toFixed(2)}%
</Badge>
)}
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6} overflowY="auto">
<VStack spacing={6} align="stretch">
{/* 概念描述 - 完整版 */}
<ModalBody pb={6}>
<VStack spacing={4} align="stretch">
{/* 显示交易日期 */}
{effectiveTradingDate && (
<HStack>
<FaCalendarAlt />
<Text fontSize="sm">
数据日期{effectiveTradingDate}
</Text>
</HStack>
)}
{/* 概念描述 */}
{selectedConcept?.description && (
<Box>
<HStack mb={3}>
<Icon as={FaChartLine} color="blue.500" />
<Text fontSize="md" fontWeight="bold">
概念解析
</Text>
</HStack>
<Box
p={4}
bg={useColorModeValue('blue.50', 'blue.900')}
borderRadius="md"
>
<Text
fontSize="sm"
color={useColorModeValue('gray.700', 'gray.300')}
lineHeight="1.8"
>
{selectedConcept.description}
</Text>
</Box>
<Text fontSize="sm" fontWeight="bold" mb={2}>
概念描述:
</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
{selectedConcept.description}
</Text>
</Box>
)}
{/* 历史触发时间线 */}
{selectedConcept?.happened_times && selectedConcept.happened_times.length > 0 && (
<Box>
<HStack mb={3}>
<Icon as={FaCalendarAlt} color="purple.500" />
<Text fontSize="md" fontWeight="bold">
历史触发时间
</Text>
</HStack>
<HStack spacing={3} flexWrap="wrap">
{selectedConcept.happened_times.map((time, idx) => (
<Badge
key={idx}
colorScheme="purple"
variant="subtle"
px={3}
py={1}
>
{time}
</Badge>
))}
</HStack>
</Box>
)}
{/* 相关股票详细列表 */}
{/* 相关股票 */}
{selectedConcept?.stocks && selectedConcept.stocks.length > 0 && (
<Box>
<HStack mb={3}>
<Icon as={FaEye} color="green.500" />
<Text fontSize="md" fontWeight="bold">
核心相关股票 ({selectedConcept.stock_count})
</Text>
</HStack>
<Box
maxH="300px"
overflowY="auto"
borderWidth={1}
borderRadius="md"
p={3}
>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
{selectedConcept.stocks.map((stock, idx) => (
<Box
key={idx}
p={3}
borderWidth={1}
borderRadius="md"
bg={useColorModeValue('white', 'gray.700')}
_hover={{
bg: useColorModeValue('gray.50', 'gray.600'),
borderColor: 'blue.300'
}}
transition="all 0.2s"
>
<HStack justify="space-between" mb={2}>
<Text fontWeight="bold" fontSize="sm">
{stock.stock_name}
</Text>
<Badge colorScheme="blue" fontSize="xs">
{stock.stock_code}
</Badge>
</HStack>
{stock.reason && (
<Text fontSize="xs" color="gray.600">
{stock.reason}
</Text>
)}
{(stock.行业 || stock.项目) && (
<HStack spacing={2} mt={2}>
{stock.行业 && (
<Badge size="sm" variant="subtle">
{stock.行业}
</Badge>
)}
{stock.项目 && (
<Badge size="sm" variant="subtle" colorScheme="green">
{stock.项目}
</Badge>
)}
</HStack>
)}
</Box>
))}
</SimpleGrid>
</Box>
<Text fontSize="sm" fontWeight="bold" mb={2}>
相关股票 ({selectedConcept.stock_count}):
</Text>
<SimpleGrid columns={2} spacing={2}>
{selectedConcept.stocks.slice(0, 6).map((stock, index) => (
<Badge key={index} p={2} variant="outline">
{stock.stock_name} ({stock.stock_code})
</Badge>
))}
</SimpleGrid>
</Box>
)}
{/* 操作按钮 */}
<HStack spacing={3} pt={4}>
<Button
colorScheme="blue"
size="lg"
flex={1}
onClick={() => {
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(selectedConcept.concept)}.html`, '_blank');
}}
leftIcon={<FaExternalLinkAlt />}
>
查看概念详情页
</Button>
<Button
variant="outline"
colorScheme="blue"
size="lg"
flex={1}
onClick={onClose}
>
关闭
</Button>
</HStack>
{/* 查看详情按钮 */}
<Button
colorScheme="blue"
onClick={() => {
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(selectedConcept.concept)}.html`, '_blank');
}}
leftIcon={<FaExternalLinkAlt />}
>
查看概念详情页
</Button>
</VStack>
</ModalBody>
</ModalContent>

View File

@@ -48,7 +48,6 @@ import {
FaSearch
} from 'react-icons/fa';
import * as echarts from 'echarts';
import StockChartModal from '../../../components/StockChart/StockChartModal';
import { eventService, stockService } from '../../../services/eventService';
@@ -71,12 +70,22 @@ const RelatedStocks = ({
const [sortOrder, setSortOrder] = useState('desc');
// 模态框状态
const {
isOpen: isAddModalOpen,
onOpen: onAddModalOpen,
onClose: onAddModalClose
} = useDisclosure();
const {
isOpen: isChartModalOpen,
onOpen: onChartModalOpen,
onClose: onChartModalClose
} = useDisclosure();
// 添加股票表单状态
const [addStockForm, setAddStockForm] = useState({
stock_code: '',
relation_desc: ''
});
// 主题和工具
const toast = useToast();
@@ -132,6 +141,41 @@ const RelatedStocks = ({
});
};
const handleAddStock = async () => {
if (!addStockForm.stock_code.trim() || !addStockForm.relation_desc.trim()) {
toast({
title: '请填写完整信息',
status: 'warning',
duration: 3000,
isClosable: true,
});
return;
}
try {
await eventService.addRelatedStock(eventId, addStockForm);
toast({
title: '添加成功',
status: 'success',
duration: 3000,
isClosable: true,
});
// 重置表单
setAddStockForm({ stock_code: '', relation_desc: '' });
onAddModalClose();
onStockAdded();
} catch (err) {
toast({
title: '添加失败',
description: err.message,
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleDeleteStock = async (stockId, stockCode) => {
if (!window.confirm(`确定要删除股票 ${stockCode} 吗?`)) {
@@ -288,6 +332,14 @@ const RelatedStocks = ({
/>
</Tooltip>
<Button
leftIcon={<FaPlus />}
size="sm"
colorScheme="blue"
onClick={onAddModalOpen}
>
添加股票
</Button>
</HStack>
</Flex>
@@ -329,6 +381,7 @@ const RelatedStocks = ({
}}>
涨跌幅 {sortField === 'change' && (sortOrder === 'asc' ? '↑' : '↓')}
</Th>
<Th textAlign="center">分时图</Th>
<Th textAlign="center" cursor="pointer" onClick={() => {
if (sortField === 'correlation') {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
@@ -414,6 +467,16 @@ const RelatedStocks = ({
</Badge>
</Td>
{/* 分时图 */}
<Td>
<Box width="120px" height="40px">
<MiniChart
stockCode={stock.stock_code}
eventTime={eventTime}
onChartClick={() => handleShowChart(stock)}
/>
</Box>
</Td>
{/* 相关度 */}
<Td textAlign="center">
@@ -438,20 +501,6 @@ const RelatedStocks = ({
{/* 操作 */}
<Td>
<HStack spacing={1}>
<Tooltip label="股票详情">
<Button
size="xs"
colorScheme="blue"
variant="solid"
onClick={() => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
}}
>
股票详情
</Button>
</Tooltip>
<Tooltip label="查看K线图">
<IconButton
icon={<FaChartLine />}
@@ -482,6 +531,14 @@ const RelatedStocks = ({
</TableContainer>
</Box>
{/* 添加股票模态框 */}
<AddStockModal
isOpen={isAddModalOpen}
onClose={onAddModalClose}
formData={addStockForm}
setFormData={setAddStockForm}
onSubmit={handleAddStock}
/>
{/* 股票图表模态框 */}
<StockChartModal
@@ -496,8 +553,401 @@ const RelatedStocks = ({
// ==================== 子组件 ====================
// 迷你分时图组件
const MiniChart = ({ stockCode, eventTime, onChartClick }) => {
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (chartRef.current && stockCode) {
loadChartData();
}
return () => {
if (chartInstanceRef.current) {
chartInstanceRef.current.dispose();
chartInstanceRef.current = null;
}
};
}, [stockCode, eventTime]);
// 现在使用统一的StockChartModal组件无需重复代码
const loadChartData = async () => {
try {
setLoading(true);
setError(null);
const response = await stockService.getKlineData(stockCode, 'timeline', eventTime);
if (!response.data || response.data.length === 0) {
setError('无数据');
return;
}
// 初始化图表
if (!chartInstanceRef.current && chartRef.current) {
chartInstanceRef.current = echarts.init(chartRef.current);
}
const option = generateMiniChartOption(response.data);
chartInstanceRef.current.setOption(option, true);
} catch (err) {
console.error('加载迷你图表失败:', err);
setError('加载失败');
} finally {
setLoading(false);
}
};
const generateMiniChartOption = (data) => {
const prices = data.map(item => item.close);
const times = data.map(item => item.time);
// 计算最高最低价格
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
// 判断是上涨还是下跌
const isUp = prices[prices.length - 1] >= prices[0];
const lineColor = isUp ? '#ef5350' : '#26a69a';
return {
grid: {
left: 2,
right: 2,
top: 2,
bottom: 2,
containLabel: false
},
xAxis: {
type: 'category',
data: times,
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false,
min: minPrice * 0.995,
max: maxPrice * 1.005
},
series: [{
data: prices,
type: 'line',
smooth: true,
symbol: 'none',
lineStyle: {
color: lineColor,
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)'
},
{
offset: 1,
color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)'
}
])
},
markLine: {
silent: true,
symbol: 'none',
label: { show: false },
lineStyle: {
color: '#aaa',
type: 'dashed',
width: 1
},
data: [{
yAxis: prices[0] // 参考价
}]
}
}],
tooltip: {
trigger: 'axis',
formatter: function(params) {
if (!params || params.length === 0) return '';
const price = params[0].value.toFixed(2);
const time = params[0].axisValue;
const percentChange = ((price - prices[0]) / prices[0] * 100).toFixed(2);
const sign = percentChange >= 0 ? '+' : '';
return `${time}<br/>价格: ${price}<br/>变动: ${sign}${percentChange}%`;
},
position: function (pos, params, el, elRect, size) {
const obj = { top: 10 };
obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 30;
return obj;
}
},
animation: false
};
};
if (loading) {
return (
<Flex align="center" justify="center" h="100%" w="100%">
<Text fontSize="xs" color="gray.500">加载中...</Text>
</Flex>
);
}
if (error) {
return (
<Flex align="center" justify="center" h="100%" w="100%">
<Text fontSize="xs" color="gray.500">{error}</Text>
</Flex>
);
}
return (
<Box
ref={chartRef}
w="100%"
h="100%"
cursor="pointer"
onClick={onChartClick}
_hover={{ opacity: 0.8 }}
/>
);
};
// 添加股票模态框组件
const AddStockModal = ({ isOpen, onClose, formData, setFormData, onSubmit }) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>添加相关股票</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>股票代码</FormLabel>
<Input
placeholder="请输入股票代码000001.SZ"
value={formData.stock_code}
onChange={(e) => setFormData(prev => ({
...prev,
stock_code: e.target.value.toUpperCase()
}))}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>关联描述</FormLabel>
<Textarea
placeholder="请描述该股票与事件的关联原因..."
value={formData.relation_desc}
onChange={(e) => setFormData(prev => ({
...prev,
relation_desc: e.target.value
}))}
rows={4}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
取消
</Button>
<Button colorScheme="blue" onClick={onSubmit}>
确定
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
// 股票图表模态框组件
const StockChartModal = ({ isOpen, onClose, stock, eventTime }) => {
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);
const [chartType, setChartType] = useState('timeline');
const [loading, setLoading] = useState(false);
const [chartData, setChartData] = useState(null);
const toast = useToast();
useEffect(() => {
if (isOpen && chartRef.current) {
loadChartData(chartType);
}
return () => {
if (chartInstanceRef.current) {
window.removeEventListener('resize', chartInstanceRef.current.resizeHandler);
chartInstanceRef.current.dispose();
chartInstanceRef.current = null;
}
};
}, [isOpen]);
useEffect(() => {
if (isOpen && chartRef.current) {
loadChartData(chartType);
}
}, [chartType, stock, eventTime]);
const loadChartData = async (type) => {
if (!stock || !chartRef.current) return;
try {
setLoading(true);
if (chartInstanceRef.current) {
chartInstanceRef.current.showLoading();
}
const response = await stockService.getKlineData(stock.stock_code, type, eventTime);
setChartData(response);
if (!chartRef.current) return;
if (!chartInstanceRef.current) {
const chart = echarts.init(chartRef.current);
chart.resizeHandler = () => chart.resize();
window.addEventListener('resize', chart.resizeHandler);
chartInstanceRef.current = chart;
}
chartInstanceRef.current.hideLoading();
const option = generateChartOption(response, type);
chartInstanceRef.current.setOption(option, true);
} catch (err) {
console.error('加载图表数据失败:', err);
toast({ title: '加载图表数据失败', description: err.message, status: 'error', duration: 3000, isClosable: true, });
if (chartInstanceRef.current) {
chartInstanceRef.current.hideLoading();
}
} finally {
setLoading(false);
}
};
const generateChartOption = (data, type) => {
if (!data || !data.data || data.data.length === 0) {
return { title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 16 } } };
}
const stockData = data.data;
if (type === 'timeline' || type === 'minute') {
const times = stockData.map(item => item.time);
const prices = stockData.map(item => item.close);
const isUp = prices[prices.length - 1] >= prices[0];
const lineColor = isUp ? '#ef5350' : '#26a69a';
return {
title: { text: `${data.name} (${data.code}) - ${type === 'timeline' ? '分时图' : '分钟线'}`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
tooltip: {
trigger: 'axis',
formatter: function(params) {
if (!params || params.length === 0) {
return '';
}
const point = params[0];
if (!point) return '';
return `时间: ${point.axisValue}<br/>价格: ¥${point.value.toFixed(2)}`;
}
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: times, boundaryGap: false },
yAxis: { type: 'value', scale: true },
series: [{ data: prices, type: 'line', smooth: true, symbol: 'none', lineStyle: { color: lineColor, width: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' }, { offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.1)' : 'rgba(38, 166, 154, 0.1)' }]) } }]
};
}
if (type === 'daily') {
const dates = stockData.map(item => item.time);
const klineData = stockData.map(item => [item.open, item.close, item.low, item.high]);
const volumes = stockData.map(item => item.volume);
return {
title: { text: `${data.name} (${data.code}) - 日K线`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function(params) {
if (!params || !Array.isArray(params) || params.length === 0) {
return '';
}
const kline = params[0];
const volume = params[1];
if (!kline || !kline.data) {
return '';
}
let tooltipHtml = `日期: ${kline.axisValue}<br/>
开盘: ${kline.data[0]}<br/>
收盘: ${kline.data[1]}<br/>
最低: ${kline.data[2]}<br/>
最高: ${kline.data[3]}`;
if (volume && typeof volume.value !== 'undefined') {
tooltipHtml += `<br/>成交量: ${volume.value}`;
}
return tooltipHtml;
}
},
grid: [{ left: '10%', right: '10%', height: '60%' }, { left: '10%', right: '10%', top: '70%', height: '16%' }],
xAxis: [{ type: 'category', data: dates, scale: true, boundaryGap: false, axisLine: { onZero: false }, splitLine: { show: false }, min: 'dataMin', max: 'dataMax' }, { type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLine: { onZero: false }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, min: 'dataMin', max: 'dataMax' }],
yAxis: [{ scale: true, splitArea: { show: true } }, { scale: true, gridIndex: 1, splitNumber: 2, axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false }, splitLine: { show: false } }],
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1], start: 80, end: 100 }, { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 10, start: 80, end: 100 }],
series: [{ name: 'K线', type: 'candlestick', data: klineData, itemStyle: { color: '#ef5350', color0: '#26a69a', borderColor: '#ef5350', borderColor0: '#26a69a' } }, { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes, itemStyle: { color: function(params) { const dataIndex = params.dataIndex; if (dataIndex === 0) return '#ef5350'; return stockData[dataIndex].close >= stockData[dataIndex - 1].close ? '#ef5350' : '#26a69a'; } } }]
};
}
return {};
};
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>
<VStack align="flex-start" spacing={2}>
<HStack>
<Text fontSize="lg" fontWeight="bold">{stock.stock_code} - 股票详情</Text>
{chartData && (<Badge colorScheme="blue">{chartData.trade_date}</Badge>)}
</HStack>
<ButtonGroup size="sm">
<Button variant={chartType === 'timeline' ? 'solid' : 'outline'} onClick={() => setChartType('timeline')} colorScheme="blue">分时图</Button>
<Button variant={chartType === 'minute' ? 'solid' : 'outline'} onClick={() => setChartType('minute')} colorScheme="blue">分钟线</Button>
<Button variant={chartType === 'daily' ? 'solid' : 'outline'} onClick={() => setChartType('daily')} colorScheme="blue">日K线</Button>
</ButtonGroup>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody p={0}>
<Box h="500px" w="100%" position="relative">
{loading && (
<Flex position="absolute" top="0" left="0" right="0" bottom="0" bg="rgba(255, 255, 255, 0.7)" zIndex="10" alignItems="center" justifyContent="center" >
<VStack spacing={4}>
<CircularProgress isIndeterminate color="blue.300" />
<Text>加载图表数据...</Text>
</VStack>
</Flex>
)}
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }}/>
</Box>
{stock?.relation_desc && (
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
<Text fontSize="sm" color="gray.600">{stock.relation_desc}</Text>
</Box>
)}
{process.env.NODE_ENV === 'development' && chartData && (
<Box p={4} bg="gray.50" fontSize="xs" color="gray.600">
<Text fontWeight="bold">调试信息:</Text>
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
<Text>交易日期: {chartData.trade_date}</Text>
<Text>图表类型: {chartData.type}</Text>
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>
);
};
export default RelatedStocks;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,43 @@
import React from 'react';
import ReactECharts from 'echarts-for-react';
const SectorPieChart = ({ sectorData }) => {
const data = Object.entries(sectorData || {}).map(([name, d]) => ({
name,
value: d.count,
}));
const option = {
title: {
text: '涨停股票分布',
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
},
series: [
{
name: '板块分布',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
};
return <ReactECharts option={option} style={{ height: 400 }} />;
};
export default SectorPieChart;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Grid, Stat, StatLabel, StatNumber, StatHelpText, StatArrow, Box } from '@chakra-ui/react';
import { FiTarget, FiBarChart2, FiTrendingUp, FiZap } from 'react-icons/fi';
const StatCard = ({ icon, label, value, color }) => (
<Box p={4} bg="white" borderRadius="lg" boxShadow="md" display="flex" alignItems="center">
<Box as={icon} boxSize={6} color={color} mr={3} />
<Box>
<StatLabel>{label}</StatLabel>
<StatNumber>{value}</StatNumber>
</Box>
</Box>
);
const StatisticsCards = ({ data }) => (
<Grid templateColumns="repeat(4, 1fr)" gap={6} mb={6}>
<StatCard icon={FiTarget} label="涨停股票总数" value={data.total_stocks} color="blue.400" />
<StatCard icon={FiBarChart2} label="涉及板块数" value={data.sector_count} color="green.400" />
<StatCard icon={FiTrendingUp} label="平均涨幅" value={data.avg_change + '%'} color="orange.400" />
<StatCard icon={FiZap} label="最大涨幅" value={data.max_change + '%'} color="red.400" />
</Grid>
);
export default StatisticsCards;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import React from 'react';
import ReactECharts from 'echarts-for-react';
const WordCloudChart = ({ sectorData }) => {
const data = Object.entries(sectorData || {}).map(([name, d]) => ({
name,
value: d.count,
}));
const option = {
title: {
text: '热点词汇',
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c}',
},
series: [
{
type: 'wordCloud',
shape: 'circle',
left: 'center',
top: 'center',
width: '90%',
height: '90%',
sizeRange: [16, 60],
rotationRange: [-30, 30],
gridSize: 12,
drawOutOfBound: false,
layoutAnimation: true,
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold',
color: () => {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
];
return colors[Math.floor(Math.random() * colors.length)];
},
},
emphasis: {
textStyle: {
shadowBlur: 10,
shadowColor: '#333',
},
},
data,
},
],
};
return <ReactECharts option={option} style={{ height: 400 }} />;
};
export default WordCloudChart;

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,28 @@
// src/views/Home/HomePage.js - 专业投资分析平台
import React, { useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
Card,
CardBody,
Badge,
Button,
// src/views/Home/HomePage.js
import React, { useState, useEffect } from 'react';
import {
Box,
Flex,
Text,
Button,
Container,
VStack,
HStack,
Icon,
Heading,
useBreakpointValue,
Link,
SimpleGrid,
Link
Divider
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import heroBg from '../../assets/img/BackgroundCard1.png';
import '../../styles/home-animations.css';
import { useAuth } from '../../contexts/AuthContext'; // 添加这个导入来调试
export default function HomePage() {
const { user, isAuthenticated, isLoading } = useAuth();
const navigate = useNavigate();
const { user, isAuthenticated, isLoading } = useAuth(); // 添加这行来调试
// 移除统计数据动画
// 保留原有的调试信息
// 添加调试信息
useEffect(() => {
console.log('🏠 HomePage AuthContext 状态:', {
user,
@@ -41,340 +37,431 @@ export default function HomePage() {
});
}, [user, isAuthenticated, isLoading]);
// 核心功能配置 - 5个主要功能
const coreFeatures = [
{
id: 'news-catalyst',
title: '新闻催化分析',
description: '实时新闻事件分析,捕捉市场催化因子',
icon: '📊',
color: 'yellow',
url: 'https://valuefrontier.cn/community',
badge: '核心',
featured: true
},
{
id: 'concepts',
title: '概念中心',
description: '热门概念与主题投资分析追踪',
icon: '🎯',
color: 'purple',
url: 'https://valuefrontier.cn/concepts',
badge: '热门'
},
{
id: 'stocks',
title: '个股信息汇总',
description: '全面的个股基本面信息整合',
icon: '📈',
color: 'blue',
url: 'https://valuefrontier.cn/stocks',
badge: '全面'
},
{
id: 'limit-analyse',
title: '涨停板块分析',
description: '涨停板数据深度分析与规律挖掘',
icon: '🚀',
color: 'green',
url: 'https://valuefrontier.cn/limit-analyse',
badge: '精准'
},
{
id: 'company',
title: '个股罗盘',
description: '个股全方位分析与投资决策支持',
icon: '🧭',
color: 'orange',
url: 'https://valuefrontier.cn/company?scode=688256',
badge: '专业'
},
{
id: 'trading-simulation',
title: '模拟盘交易',
description: '100万起始资金体验真实交易环境',
icon: '💰',
color: 'teal',
url: '/trading-simulation',
badge: '实战'
}
];
// 统计数据动画
const [stats, setStats] = useState({
dataSize: 0,
dataSources: 0,
researchTargets: 0
});
// 个人中心配置
const personalCenter = {
title: '个人中心',
description: '账户管理与个人设置',
icon: '👤',
color: 'gray',
url: 'https://valuefrontier.cn/home/center'
};
useEffect(() => {
const targetStats = {
dataSize: 17,
dataSources: 300,
researchTargets: 45646
};
const handleProductClick = (url) => {
if (url.startsWith('http')) {
// 外部链接,直接打开
window.open(url, '_blank');
} else {
// 内部路由
navigate(url);
}
};
// 动画效果
const animateStats = () => {
const duration = 2000; // 2秒动画
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
setStats({
dataSize: Math.floor(targetStats.dataSize * progress),
dataSources: Math.floor(targetStats.dataSources * progress),
researchTargets: Math.floor(targetStats.researchTargets * progress)
});
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
};
const timer = setTimeout(animateStats, 500);
return () => clearTimeout(timer);
}, []);
return (
<Box>
{/* 开发调试信息 */}
{process.env.NODE_ENV === 'development' && (
<Box bg="rgba(0, 0, 0, 0.8)" color="yellow.200" p={2} fontSize="xs" zIndex={1000} position="relative">
认证: {isAuthenticated ? '✅' : '❌'} | 用户: {user?.nickname || '无'}
</Box>
)}
<Box>
{/* 临时调试信息栏 - 完成调试后可以删除 */}
{process.env.NODE_ENV === 'development' && (
<Box bg="yellow.100" p={2} fontSize="sm" borderBottom="1px solid" borderColor="yellow.300">
<Text fontWeight="bold">🐛 调试信息:</Text>
<Text>认证状态: {isAuthenticated ? '✅ 已登录' : '❌ 未登录'}</Text>
<Text>加载状态: {isLoading ? '⏳ 加载中' : '✅ 加载完成'}</Text>
<Text>用户信息: {user ? `👤 ${user.nickname || user.username} (ID: ${user.id})` : '❌ 无用户信息'}</Text>
<Text>localStorage: {localStorage.getItem('user') ? '✅ 有数据' : '❌ 无数据'}</Text>
</Box>
)}
{/* Hero Section - 深色科技风格 */}
<Box
position="relative"
minH="100vh"
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
overflow="hidden"
>
{/* 背景图片和装饰 */}
{/* Hero Section - 主要横幅 */}
<Box
position="absolute"
top="0"
right="0"
w="50%"
h="100%"
bgImage={`url(${heroBg})`}
bgSize="cover"
bgPosition="center"
opacity={0.3}
_after={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(90deg, rgba(14, 12, 21, 0.9) 0%, rgba(14, 12, 21, 0.3) 100%)'
}}
/>
{/* 装饰性几何图形 */}
<Box
position="absolute"
top="20%"
left="10%"
w="200px"
h="200px"
borderRadius="50%"
bg="rgba(255, 215, 0, 0.1)"
filter="blur(80px)"
className="float-animation"
/>
<Box
position="absolute"
bottom="30%"
right="20%"
w="150px"
h="150px"
borderRadius="50%"
bg="rgba(138, 43, 226, 0.1)"
filter="blur(60px)"
className="float-animation-reverse"
/>
position="relative"
minH="85vh"
bgImage="url('https://images.unsplash.com/photo-1682686581427-7c80ab60e3f3?q=80&w=1400&auto=format&fit=crop')"
bgSize="cover"
bgPosition="center"
display="flex"
alignItems="center"
justifyContent="center"
>
{/* 背景遮罩 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
zIndex={1}
/>
<Container maxW="7xl" position="relative" zIndex={2}>
<VStack spacing={16} align="stretch" minH="100vh" justify="center">
{/* 主标题区域 */}
<VStack spacing={6} textAlign="center" pt={20}>
<Heading
size="4xl"
color="white"
fontWeight="900"
letterSpacing="-2px"
lineHeight="shorter"
{/* 内容 */}
<Container maxW="container.lg" position="relative" zIndex={2}>
<VStack spacing={6} textAlign="center" color="white">
<Heading
as="h1"
size="2xl"
fontWeight="bold"
lineHeight={1.2}
>
智能投资分析平台
价值前沿
<br />
专研人工智能投资助手
</Heading>
<Text fontSize="xl" color="whiteAlpha.800" maxW="3xl" lineHeight="tall">
专业投资研究工具助您把握市场机遇
<Text fontSize="xl" opacity={0.9} maxW="600px">
人工智能驱动的AI投资助手
<br />
领略超过ChatGPT的人工智能投研应用
</Text>
</VStack>
</Container>
{/* 个人中心入口 */}
<Flex justify="flex-end" pr={4}>
<Button
leftIcon={<Text fontSize="lg">{personalCenter.icon}</Text>}
colorScheme="gray"
variant="ghost"
size="md"
color="whiteAlpha.800"
_hover={{
bg: 'whiteAlpha.200',
color: 'white'
}}
onClick={() => handleProductClick(personalCenter.url)}
>
{personalCenter.title}
</Button>
</Flex>
{/* 波浪底部 */}
<Box
position="absolute"
bottom={0}
left={0}
right={0}
height="100px"
zIndex={2}
sx={{
'&::before': {
content: '""',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '100px',
background: 'linear-gradient(to top, white 0%, transparent 100%)',
}
}}
/>
</Box>
{/* 核心功能面板 */}
<Box>
<VStack spacing={8}>
<Heading size="2xl" color="white" textAlign="center">
核心功能
</Heading>
{/* 新闻催化分析 - 突出显示 */}
<Card
bg="transparent"
border="2px solid"
borderColor="yellow.400"
borderRadius="3xl"
overflow="hidden"
position="relative"
shadow="2xl"
w="100%"
_before={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 165, 0, 0.05) 100%)',
zIndex: 0
}}
>
<CardBody p={8} position="relative" zIndex={1}>
<Flex align="center" justify="space-between">
<HStack spacing={6}>
<Box
p={4}
borderRadius="xl"
bg="yellow.400"
color="black"
>
<Text fontSize="3xl">{coreFeatures[0].icon}</Text>
</Box>
<VStack align="start" spacing={2}>
<HStack>
<Heading size="xl" color="white">
{coreFeatures[0].title}
</Heading>
<Badge colorScheme="yellow" variant="solid" fontSize="sm">
{coreFeatures[0].badge}
</Badge>
</HStack>
<Text color="whiteAlpha.800" fontSize="lg" maxW="md">
{coreFeatures[0].description}
</Text>
</VStack>
</HStack>
<Button
colorScheme="yellow"
size="lg"
borderRadius="full"
fontWeight="bold"
onClick={() => handleProductClick(coreFeatures[0].url)}
>
进入功能
</Button>
</Flex>
</CardBody>
</Card>
{/* 统计数据区域 */}
<Box py={12} position="relative" mt={-20} zIndex={3}>
<Container maxW="container.lg">
<Box
bg="white"
borderRadius="xl"
boxShadow="2xl"
p={8}
backdropFilter="blur(10px)"
>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8}>
<VStack textAlign="center" spacing={4}>
<Heading size="2xl" bgGradient="linear(to-r, gray.700, gray.900)" bgClip="text">
{stats.dataSize}TB
</Heading>
<Heading size="lg" color="gray.700">基础数据</Heading>
<Text color="gray.600" fontSize="sm">
我们收集来自全世界的各类数据打造您的专属智能投资助手
</Text>
</VStack>
{/* 其他5个功能 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} w="100%">
{coreFeatures.slice(1).map((feature, index) => (
<Card
key={feature.id}
bg="whiteAlpha.100"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="2xl"
cursor="pointer"
transition="all 0.3s ease"
_hover={{
bg: 'whiteAlpha.200',
borderColor: `${feature.color}.400`,
transform: 'translateY(-4px)',
shadow: '2xl'
}}
onClick={() => handleProductClick(feature.url)}
minH="180px"
>
<CardBody p={6}>
<VStack spacing={4} align="start" h="100%">
<HStack>
<Box
p={3}
borderRadius="lg"
bg={`${feature.color}.50`}
border="1px solid"
borderColor={`${feature.color}.200`}
>
<Text fontSize="2xl">{feature.icon}</Text>
</Box>
<Badge colorScheme={feature.color} variant="solid">
{feature.badge}
</Badge>
</HStack>
<VStack align="start" spacing={2} flex={1}>
<Heading size="lg" color="white">
{feature.title}
</Heading>
<Text color="whiteAlpha.800" fontSize="sm" lineHeight="tall">
{feature.description}
</Text>
</VStack>
<Button
colorScheme={feature.color}
size="sm"
variant="outline"
alignSelf="flex-end"
onClick={() => handleProductClick(feature.url)}
>
使用
</Button>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</VStack>
<VStack textAlign="center" spacing={4}>
<Heading size="2xl" bgGradient="linear(to-r, gray.700, gray.900)" bgClip="text">
{stats.dataSources}+
</Heading>
<Heading size="lg" color="gray.700">数据源</Heading>
<Text color="gray.600" fontSize="sm">
我们即时采集来自300多家数据源的实时数据随时满足您的投研需求
</Text>
</VStack>
<VStack textAlign="center" spacing={4}>
<Heading size="2xl" bgGradient="linear(to-r, gray.700, gray.900)" bgClip="text">
{stats.researchTargets.toLocaleString()}
</Heading>
<Heading size="lg" color="gray.700">研究标的</Heading>
<Text color="gray.600" fontSize="sm">
我们的研究范围涵盖全球主流市场包括股票外汇大宗等交易类型给您足够宏观的视角
</Text>
</VStack>
</SimpleGrid>
</Box>
</VStack>
</Container>
</Box>
</Container>
</Box>
{/* 底部区域 */}
<Box
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 100%)"
py={12}
position="relative"
>
<Container maxW="7xl" position="relative" zIndex={1}>
<VStack spacing={6} textAlign="center">
<Text color="whiteAlpha.600" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="whiteAlpha.500">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
color="whiteAlpha.500"
_hover={{ color: 'whiteAlpha.700' }}
>
京公网安备11010802046286号
{/* 特色功能介绍 - 完全复刻原网站 */}
<Box as="section" py={20} bg="white">
<Container maxW="container.xl">
<Flex align="center" gap={16}>
{/* 左侧功能介绍 - 完全按照原网站布局 */}
<Box flex="1" ml="auto">
{/* 第一行 */}
<SimpleGrid columns={2} spacing={8} mb={12}>
<Box>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 40 44" w="25px" h="25px" color="gray.700">
<path fill="currentColor" d="M40,40 L36.3636364,40 L36.3636364,3.63636364 L5.45454545,3.63636364 L5.45454545,0 L38.1818182,0 C39.1854545,0 40,0.814545455 40,1.81818182 L40,40 Z" opacity="0.603585379"/>
<path fill="currentColor" d="M30.9090909,7.27272727 L1.81818182,7.27272727 C0.814545455,7.27272727 0,8.08727273 0,9.09090909 L0,41.8181818 C0,42.8218182 0.814545455,43.6363636 1.81818182,43.6363636 L30.9090909,43.6363636 C31.9127273,43.6363636 32.7272727,42.8218182 32.7272727,41.8181818 L32.7272727,9.09090909 C32.7272727,8.08727273 31.9127273,7.27272727 30.9090909,7.27272727 Z M18.1818182,34.5454545 L7.27272727,34.5454545 L7.27272727,30.9090909 L18.1818182,30.9090909 L18.1818182,34.5454545 Z M25.4545455,27.2727273 L7.27272727,27.2727273 L7.27272727,23.6363636 L25.4545455,23.6363636 L25.4545455,27.2727273 Z M25.4545455,20 L7.27272727,20 L7.27272727,16.3636364 L25.4545455,16.3636364 L25.4545455,20 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3}>人工智能驱动</Heading>
<Text color="gray.600" fontSize="sm" pr="5">
收集海量投研资料和数据确保信息全面丰富<br/>
训练专注于投研的大语言模型专业度领先<br/>
在金融投资领域表现卓越优于市面其他模型
</Text>
</VStack>
</Box>
<Box>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 45 40" w="25px" h="25px" color="gray.700">
<path fill="currentColor" d="M46.7199583,10.7414583 L40.8449583,0.949791667 C40.4909749,0.360605034 39.8540131,0 39.1666667,0 L7.83333333,0 C7.1459869,0 6.50902508,0.360605034 6.15504167,0.949791667 L0.280041667,10.7414583 C0.0969176761,11.0460037 -1.23209662e-05,11.3946378 -1.23209662e-05,11.75 C-0.00758042603,16.0663731 3.48367543,19.5725301 7.80004167,19.5833333 L7.81570833,19.5833333 C9.75003686,19.5882688 11.6168794,18.8726691 13.0522917,17.5760417 C16.0171492,20.2556967 20.5292675,20.2556967 23.494125,17.5760417 C26.4604562,20.2616016 30.9794188,20.2616016 33.94575,17.5760417 C36.2421905,19.6477597 39.5441143,20.1708521 42.3684437,18.9103691 C45.1927731,17.649886 47.0084685,14.8428276 47.0000295,11.75 C47.0000295,11.3946378 46.9030823,11.0460037 46.7199583,10.7414583 Z" opacity="0.598981585"/>
<path fill="currentColor" d="M39.198,22.4912623 C37.3776246,22.4928106 35.5817531,22.0149171 33.951625,21.0951667 L33.92225,21.1107282 C31.1430221,22.6838032 27.9255001,22.9318916 24.9844167,21.7998837 C24.4750389,21.605469 23.9777983,21.3722567 23.4960833,21.1018359 L23.4745417,21.1129513 C20.6961809,22.6871153 17.4786145,22.9344611 14.5386667,21.7998837 C14.029926,21.6054643 13.533337,21.3722507 13.0522917,21.1018359 C11.4250962,22.0190609 9.63246555,22.4947009 7.81570833,22.4912623 C7.16510551,22.4842162 6.51607673,22.4173045 5.875,22.2911849 L5.875,44.7220845 C5.875,45.9498589 6.7517757,46.9451667 7.83333333,46.9451667 L19.5833333,46.9451667 L19.5833333,33.6066734 L27.4166667,33.6066734 L27.4166667,46.9451667 L39.1666667,46.9451667 C40.2482243,46.9451667 41.125,45.9498589 41.125,44.7220845 L41.125,22.2822926 C40.4887822,22.4116582 39.8442868,22.4815492 39.198,22.4912623 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3}>投研数据湖</Heading>
<Text color="gray.600" fontSize="sm" pr="3">
AI Agent 24/7 全天候采集全球数据确保实时更新<br/>
整合多种数据源覆盖范围广泛信息丰富<br/>
构建独特数据湖提供业内无可比拟的数据深度
</Text>
</VStack>
</Box>
</SimpleGrid>
{/* 第二行 */}
<SimpleGrid columns={2} spacing={8}>
<Box mt={3}>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 42 44" w="25px" h="25px" color="gray.700">
<path fill="currentColor" d="M18.8086957,4.70034783 C15.3814926,0.343541521 9.0713063,-0.410050841 4.7145,3.01715217 C0.357693695,6.44435519 -0.395898667,12.7545415 3.03130435,17.1113478 C5.53738466,10.3360568 11.6337901,5.54042955 18.8086957,4.70034783 L18.8086957,4.70034783 Z" opacity="0.6"/>
<path fill="currentColor" d="M38.9686957,17.1113478 C42.3958987,12.7545415 41.6423063,6.44435519 37.2855,3.01715217 C32.9286937,-0.410050841 26.6185074,0.343541521 23.1913043,4.70034783 C30.3662099,5.54042955 36.4626153,10.3360568 38.9686957,17.1113478 Z" opacity="0.6"/>
<path fill="currentColor" d="M34.3815652,34.7668696 C40.2057958,27.7073059 39.5440671,17.3375603 32.869743,11.0755718 C26.1954189,4.81358341 15.8045811,4.81358341 9.13025701,11.0755718 C2.45593289,17.3375603 1.79420418,27.7073059 7.61843478,34.7668696 L3.9753913,40.0506522 C3.58549114,40.5871271 3.51710058,41.2928217 3.79673036,41.8941824 C4.07636014,42.4955431 4.66004722,42.8980248 5.32153275,42.9456105 C5.98301828,42.9931963 6.61830436,42.6784048 6.98113043,42.1232609 L10.2744783,37.3434783 C16.5555112,42.3298213 25.4444888,42.3298213 31.7255217,37.3434783 L35.0188696,42.1196087 C35.6014207,42.9211577 36.7169135,43.1118605 37.53266,42.5493622 C38.3484064,41.9868639 38.5667083,40.8764423 38.0246087,40.047 L34.3815652,34.7668696 Z M30.1304348,25.5652174 L21,25.5652174 C20.49574,25.5652174 20.0869565,25.1564339 20.0869565,24.6521739 L20.0869565,15.5217391 C20.0869565,15.0174791 20.49574,14.6086957 21,14.6086957 C21.50426,14.6086957 21.9130435,15.0174791 21.9130435,15.5217391 L21.9130435,23.7391304 L30.1304348,23.7391304 C30.6346948,23.7391304 31.0434783,24.1479139 31.0434783,24.6521739 C31.0434783,25.1564339 30.6346948,25.5652174 30.1304348,25.5652174 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3}>投研Agent</Heading>
<Text color="gray.600" fontSize="sm" pr="5">
采用 AI 模拟人类分析师智能化程度高<br/>
具备独特的全球视角全面审视各类资产<br/>
提供最佳投资建议支持科学决策
</Text>
</VStack>
</Box>
<Box mt={3}>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 42 42" w="25px" h="25px" color="gray.700">
<path fill="currentColor" d="M12.25,17.5 L8.75,17.5 L8.75,1.75 C8.75,0.78225 9.53225,0 10.5,0 L31.5,0 C32.46775,0 33.25,0.78225 33.25,1.75 L33.25,12.25 L29.75,12.25 L29.75,3.5 L12.25,3.5 L12.25,17.5 Z" opacity="0.6"/>
<path fill="currentColor" d="M40.25,14 L24.5,14 C23.53225,14 22.75,14.78225 22.75,15.75 L22.75,38.5 L19.25,38.5 L19.25,22.75 C19.25,21.78225 18.46775,21 17.5,21 L1.75,21 C0.78225,21 0,21.78225 0,22.75 L0,40.25 C0,41.21775 0.78225,42 1.75,42 L40.25,42 C41.21775,42 42,41.21775 42,40.25 L42,15.75 C42,14.78225 41.21775,14 40.25,14 Z M12.25,36.75 L7,36.75 L7,33.25 L12.25,33.25 L12.25,36.75 Z M12.25,29.75 L7,29.75 L7,26.25 L12.25,26.25 L12.25,29.75 Z M35,36.75 L29.75,36.75 L29.75,33.25 L35,33.25 L35,36.75 Z M35,29.75 L29.75,29.75 L29.75,26.25 L35,26.25 L35,29.75 Z M35,22.75 L29.75,22.75 L29.75,19.25 L35,19.25 L35,22.75 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3}>新闻事件驱动</Heading>
<Text color="gray.600" fontSize="sm" pr="3">
基于AI的信息挖掘技术<br/>
Agent 赋能的未来推演和数据关联<br/>
自由交流我们相信集体的力量
</Text>
</VStack>
</Box>
{/* 深研系统 → 盈利预测报表 入口 */}
<Box mt={3}>
<VStack align="start" spacing={3}>
<Heading size="md" fontWeight="bold" mt={1}>深研系统</Heading>
<Button
size="sm"
colorScheme="purple"
variant="ghost"
onClick={() => navigate('/admin/stock-analysis/forecast-report')}
>
盈利预测报表
</Button>
</VStack>
</Box>
</SimpleGrid>
</Box>
{/* 右侧卡片 - 完全按照原网站设计 */}
<Box flex="0 0 auto" w="400px" p={4}>
<Box
position="relative"
borderRadius="xl"
overflow="hidden"
transform="perspective(1000px) rotateY(-5deg)"
boxShadow="2xl"
bgImage="url('https://raw.githubusercontent.com/creativetimofficial/public-assets/master/soft-ui-design-system/assets/img/team-working.jpg')"
bgSize="cover"
bgPosition="center"
h="400px"
>
{/* 黑色遮罩 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
/>
{/* 内容 */}
<Flex
direction="column"
align="center"
justify="center"
h="full"
position="relative"
zIndex={1}
color="white"
textAlign="center"
pt={7}
>
{/* 3D盒子图标 */}
<Box className="icon icon-lg up" mb={3} mt={3}>
<Icon viewBox="0 0 42 42" w="50px" h="50px" color="white">
<path fill="currentColor" d="M22.7597136,19.3090182 L38.8987031,11.2395234 C39.3926816,10.9925342 39.592906,10.3918611 39.3459167,9.89788265 C39.249157,9.70436312 39.0922432,9.5474453 38.8987261,9.45068056 L20.2741875,0.1378125 L20.2741875,0.1378125 C19.905375,-0.04725 19.469625,-0.04725 19.0995,0.1378125 L3.1011696,8.13815822 C2.60720568,8.38517662 2.40701679,8.98586148 2.6540352,9.4798254 C2.75080129,9.67332903 2.90771305,9.83023153 3.10122239,9.9269862 L21.8652864,19.3090182 C22.1468139,19.4497819 22.4781861,19.4497819 22.7597136,19.3090182 Z"/>
<path fill="currentColor" d="M23.625,22.429159 L23.625,39.8805372 C23.625,40.4328219 24.0727153,40.8805372 24.625,40.8805372 C24.7802551,40.8805372 24.9333778,40.8443874 25.0722402,40.7749511 L41.2741875,32.673375 L41.2741875,32.673375 C41.719125,32.4515625 42,31.9974375 42,31.5 L42,14.241659 C42,13.6893742 41.5522847,13.241659 41,13.241659 C40.8447549,13.241659 40.6916418,13.2778041 40.5527864,13.3472318 L24.1777864,21.5347318 C23.8390024,21.7041238 23.625,22.0503869 23.625,22.429159 Z" opacity="0.7"/>
<path fill="currentColor" d="M20.4472136,21.5347318 L1.4472136,12.0347318 C0.953235098,11.7877425 0.352562058,11.9879669 0.105572809,12.4819454 C0.0361450918,12.6208008 6.47121774e-16,12.7739139 0,12.929159 L0,30.1875 L0,30.1875 C0,30.6849375 0.280875,31.1390625 0.7258125,31.3621875 L19.5528096,40.7750766 C20.0467945,41.0220531 20.6474623,40.8218132 20.8944388,40.3278283 C20.963859,40.1889789 21,40.0358742 21,39.8806379 L21,22.429159 C21,22.0503869 20.7859976,21.7041238 20.4472136,21.5347318 Z" opacity="0.7"/>
</Icon>
</Box>
<Heading size="xl" color="white" className="up" mb={0} lineHeight="1.2">
事件催化<br />让成功有迹可循
</Heading>
<Button
onClick={() => navigate('/community')}
variant="outline"
colorScheme="whiteAlpha"
size="lg"
mt={3}
className="up btn-round"
borderRadius="full"
borderColor="white"
color="white"
_hover={{
bg: "whiteAlpha.200",
borderColor: "white",
transform: "translateY(-2px)"
}}
_active={{ transform: "translateY(0)" }}
transition="all 0.2s"
>
访问新闻催化分析
</Button>
</Flex>
</Box>
</Box>
</Flex>
</Container>
</Box>
{/* AI投研专题应用区域 - 完全按照原网站样式 */}
<Box as="section" py={20} bg="white">
<Container maxW="container.xl">
<VStack spacing={12} textAlign="center">
<VStack spacing={2}>
<Heading size="xl" color="gray.800" mb={0}>
AI投研专题应用
</Heading>
<Heading
size="xl"
color="#facc15"
fontWeight="bold"
>
By 价小前投研
</Heading>
<Text fontSize="lg" color="gray.600" fontWeight="medium">
人工智能+专业投研流程最强投资AI助手
</Text>
</VStack>
</VStack>
</Container>
</Box>
{/* 页脚 - 使用原网站的黄色系配色 */}
<Box as="footer" bg="white" color="gray.800" py={16}>
<Container maxW="container.xl">
<SimpleGrid columns={{ base: 2, md: 5 }} spacing={8}>
{/* 价值前沿 */}
<VStack align="start" spacing={4}>
<Heading size="md" color="#FFB800">价值前沿</Heading>
<Text fontSize="sm" color="gray.700" fontWeight="bold">
更懂投资者的AI投研平台
</Text>
</VStack>
{/* 关于我们 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="#FFB800">关于我们</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>公司介绍</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>团队架构</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>联系方式</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>反馈评价</Link>
</VStack>
</VStack>
{/* 免费资源 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="#FFB800">免费资源</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>投研日报</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>资讯速递</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>免费试用</Link>
</VStack>
</VStack>
{/* 产品介绍 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="#FFB800">产品介绍</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>行情复盘</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>高频跟踪</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>深研系统</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>了解更多</Link>
</VStack>
</VStack>
{/* 产品下载 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="#FFB800">产品下载</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>手机APP</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>Win终端</Link>
<Link fontSize="sm" color="gray.700" _hover={{ color: "#FFB800" }}>Mac终端</Link>
</VStack>
</VStack>
</SimpleGrid>
{/* 版权信息 */}
<Divider my={8} />
<Text textAlign="center" fontSize="sm" color="gray.400">
All rights reserved. Copyright © {new Date().getFullYear()} 投研系统 by{' '}
<Link color="#FFB800" _hover={{ textDecoration: "underline" }}>
价值前沿
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
.
</Text>
</Container>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect } from 'react';
import React, { useMemo } from 'react';
import {
Box,
Card,
@@ -36,9 +36,7 @@ import {
WrapItem,
useColorModeValue,
} from '@chakra-ui/react';
import { getFormattedTextProps } from '../../../utils/textUtils';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import './WordCloud.css';
import {
BarChart, Bar,
PieChart, Pie, Cell,
@@ -50,7 +48,7 @@ import {
Treemap,
Area, AreaChart,
} from 'recharts';
import ReactWordcloud from 'react-wordcloud';
// 颜色配置
const CHART_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
@@ -69,34 +67,70 @@ const WordCloud = ({ data }) => {
);
}
const words = data.slice(0, 100).map(item => ({
text: item.name || item.text,
value: item.value || item.count || 1
const treemapData = data.slice(0, 30).map((item, index) => ({
name: item.name,
size: item.value,
color: CHART_COLORS[index % CHART_COLORS.length]
}));
const options = {
rotations: 2,
rotationAngles: [-90, 0],
fontFamily: 'Microsoft YaHei, sans-serif',
fontSizes: [16, 80],
fontWeight: 'bold',
padding: 3,
scale: 'sqrt',
};
const CustomContent = (props) => {
const { x, y, width, height, name, size, color } = props;
const fontSize = Math.min(Math.max(Math.sqrt(width * height) / 4, 10), 24);
const callbacks = {
getWordColor: () => {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
return colors[Math.floor(Math.random() * colors.length)];
}
if (width < 30 || height < 20) return null;
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
style={{
fill: color,
stroke: '#fff',
strokeWidth: 2,
strokeOpacity: 1,
}}
/>
<text
x={x + width / 2}
y={y + height / 2}
textAnchor="middle"
dominantBaseline="central"
fill="white"
fontSize={fontSize}
fontWeight="bold"
>
{name}
</text>
{width > 50 && height > 40 && (
<text
x={x + width / 2}
y={y + height / 2 + fontSize + 2}
textAnchor="middle"
dominantBaseline="central"
fill="white"
fontSize={fontSize * 0.6}
opacity={0.8}
>
{size}
</text>
)}
</g>
);
};
return (
<ReactWordcloud
words={words}
options={options}
callbacks={callbacks}
/>
<ResponsiveContainer width="100%" height={400}>
<Treemap
data={treemapData}
dataKey="size"
aspectRatio={4 / 3}
stroke="#fff"
content={<CustomContent />}
/>
</ResponsiveContainer>
);
};
@@ -576,9 +610,7 @@ export const StockDetailModal = ({ isOpen, onClose, selectedStock }) => {
<Box>
<Text fontWeight="bold" mb={2}>详细分析</Text>
<Box p={4} bg="gray.50" borderRadius="md">
<Text {...getFormattedTextProps(selectedStock.summary).props}>
{getFormattedTextProps(selectedStock.summary).children}
</Text>
<Text whiteSpace="pre-wrap">{selectedStock.summary}</Text>
</Box>
</Box>
</>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import {
Box,
Card,
@@ -18,30 +18,9 @@ import {
} from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
const EnhancedCalendar = ({
selectedDate,
onDateChange,
availableDates,
compact = false,
hideSelectionInfo = false,
hideLegend = false,
width,
cellHeight,
}) => {
const EnhancedCalendar = ({ selectedDate, onDateChange, availableDates }) => {
const [currentMonth, setCurrentMonth] = useState(new Date());
// 当外部选择日期变化时,如果不在当前月,则切换到对应月份
useEffect(() => {
if (selectedDate) {
const isSameMonth =
currentMonth.getFullYear() === selectedDate.getFullYear() &&
currentMonth.getMonth() === selectedDate.getMonth();
if (!isSameMonth) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
}
}
}, [selectedDate]);
const getDaysInMonth = (date) => {
const year = date.getFullYear();
const month = date.getMonth();
@@ -100,38 +79,34 @@ const EnhancedCalendar = ({
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
const calendarWidth = width ? width : (compact ? '420px' : '500px');
const dayCellHeight = cellHeight ? cellHeight : (compact ? 12 : 16); // Chakra units
const headerSize = compact ? 'md' : 'lg';
return (
<Card bg="white" boxShadow="2xl" borderRadius="xl" w={calendarWidth}>
<CardHeader pb={compact ? 2 : 3}>
<Card bg="white" boxShadow="2xl" borderRadius="xl" w="500px">
<CardHeader pb={3}>
<VStack spacing={3}>
<HStack justify="space-between" w="full">
<IconButton
icon={<ChevronLeftIcon />}
size={compact ? 'sm' : 'md'}
size="md"
variant="ghost"
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}
aria-label="上个月"
/>
<HStack spacing={2}>
<CalendarIcon boxSize={5} />
<Heading size={headerSize}>
<Heading size="lg">
{currentMonth.getFullYear()} {monthNames[currentMonth.getMonth()]}
</Heading>
</HStack>
<IconButton
icon={<ChevronRightIcon />}
size={compact ? 'sm' : 'md'}
size="md"
variant="ghost"
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}
aria-label="下个月"
/>
</HStack>
{!hideSelectionInfo && selectedDate && (
{selectedDate && (
<Alert status="info" borderRadius="md" fontSize="sm">
<AlertIcon />
<Text>
@@ -146,10 +121,10 @@ const EnhancedCalendar = ({
</CardHeader>
<CardBody pt={2}>
<SimpleGrid columns={7} spacing={compact ? 1 : 2}>
<SimpleGrid columns={7} spacing={2}>
{weekDays.map(day => (
<Box key={day} textAlign="center" p={compact ? 2 : 3}>
<Text fontSize={compact ? 'sm' : 'md'} fontWeight="bold" color="gray.600">
<Box key={day} textAlign="center" p={3}>
<Text fontSize="md" fontWeight="bold" color="gray.600">
{day}
</Text>
</Box>
@@ -180,7 +155,7 @@ const EnhancedCalendar = ({
<Box
as="button"
w="full"
h={dayCellHeight}
h={16}
borderRadius="lg"
position="relative"
bg={hasData ? getDateColor(dateData.count) : 'transparent'}
@@ -197,7 +172,7 @@ const EnhancedCalendar = ({
cursor="pointer"
>
<Text
fontSize={compact ? 'md' : 'lg'}
fontSize="lg"
fontWeight={isToday || isSelected ? 'bold' : 'normal'}
color={isSelected ? 'blue.600' : 'gray.700'}
>
@@ -208,11 +183,11 @@ const EnhancedCalendar = ({
position="absolute"
top="2px"
right="2px"
size={compact ? 'sm' : 'md'}
size="md"
colorScheme={getDateBadgeColor(dateData.count)}
fontSize={compact ? '10px' : '11px'}
px={compact ? 1 : 2}
minW={compact ? '22px' : '28px'}
fontSize="11px"
px={2}
minW="28px"
borderRadius="full"
>
{dateData.count}
@@ -224,7 +199,7 @@ const EnhancedCalendar = ({
bottom="2px"
left="50%"
transform="translateX(-50%)"
fontSize={compact ? '9px' : '10px'}
fontSize="10px"
color="blue.500"
fontWeight="bold"
>
@@ -239,28 +214,25 @@ const EnhancedCalendar = ({
})}
</SimpleGrid>
{!hideLegend && (
<>
<Divider my={4} />
<VStack spacing={3}>
<Text fontSize="sm" fontWeight="bold" color="gray.600">涨停数量图例</Text>
<HStack justify="center" spacing={4}>
<HStack spacing={2}>
<Box w={5} h={5} bg="green.100" borderRadius="md" border="1px solid" borderColor="green.300" />
<Text fontSize="sm">少量 (50)</Text>
</HStack>
<HStack spacing={2}>
<Box w={5} h={5} bg="yellow.100" borderRadius="md" border="1px solid" borderColor="yellow.400" />
<Text fontSize="sm">中等 (51-80)</Text>
</HStack>
<HStack spacing={2}>
<Box w={5} h={5} bg="red.100" borderRadius="md" border="1px solid" borderColor="red.300" />
<Text fontSize="sm">大量 (&gt;80)</Text>
</HStack>
</HStack>
</VStack>
</>
)}
<Divider my={4} />
<VStack spacing={3}>
<Text fontSize="sm" fontWeight="bold" color="gray.600">涨停数量图例</Text>
<HStack justify="center" spacing={4}>
<HStack spacing={2}>
<Box w={5} h={5} bg="green.100" borderRadius="md" border="1px solid" borderColor="green.300" />
<Text fontSize="sm">少量 (50)</Text>
</HStack>
<HStack spacing={2}>
<Box w={5} h={5} bg="yellow.100" borderRadius="md" border="1px solid" borderColor="yellow.400" />
<Text fontSize="sm">中等 (51-80)</Text>
</HStack>
<HStack spacing={2}>
<Box w={5} h={5} bg="red.100" borderRadius="md" border="1px solid" borderColor="red.300" />
<Text fontSize="sm">大量 (>80)</Text>
</HStack>
</HStack>
</VStack>
</CardBody>
</Card>
);

View File

@@ -39,7 +39,6 @@ import {
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { formatTooltipText, getFormattedTextProps } from '../../../utils/textUtils';
import { SearchIcon, CalendarIcon, ViewIcon, ExternalLinkIcon, DownloadIcon } from '@chakra-ui/icons';
// 高级搜索组件
@@ -287,26 +286,9 @@ export const SearchResultsModal = ({ isOpen, onClose, searchResults, onStockClic
)}
</Td>
<Td maxW="300px">
<Tooltip
label={formatTooltipText(stock.brief || stock.summary)}
placement="top"
hasArrow
bg="gray.800"
color="white"
px={3}
py={2}
borderRadius="md"
fontSize="sm"
maxW="400px"
whiteSpace="pre-line"
>
<Text
fontSize="sm"
noOfLines={2}
{...getFormattedTextProps(stock.brief || stock.summary || '-').props}
_hover={{ cursor: 'help' }}
>
{getFormattedTextProps(stock.brief || stock.summary || '-').children}
<Tooltip label={stock.brief || stock.summary} placement="top">
<Text fontSize="sm" noOfLines={2}>
{stock.brief || stock.summary || '-'}
</Text>
</Tooltip>
</Td>

View File

@@ -23,19 +23,14 @@ import {
WrapItem,
Button,
useColorModeValue,
Collapse,
useDisclosure,
} from '@chakra-ui/react';
import { StarIcon, ViewIcon, TimeIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { getFormattedTextProps } from '../../../utils/textUtils';
const SectorDetails = ({ sortedSectors, totalStocks }) => {
const SectorDetails = ({ sortedSectors, totalStocks, onStockClick }) => {
// 使用 useRef 来维持展开状态,避免重新渲染时重置
const expandedSectorsRef = useRef([]);
const [expandedSectors, setExpandedSectors] = useState([]);
const [isInitialized, setIsInitialized] = useState(false);
// 新增:管理每个股票涨停原因的展开状态
const [expandedStockReasons, setExpandedStockReasons] = useState({});
const cardBg = useColorModeValue('white', 'gray.800');
@@ -61,14 +56,6 @@ const SectorDetails = ({ sortedSectors, totalStocks }) => {
}
};
// 新增:切换股票涨停原因的展开状态
const toggleStockReason = (stockCode) => {
setExpandedStockReasons(prev => ({
...prev,
[stockCode]: !prev[stockCode]
}));
};
const getSectorColorScheme = (sector) => {
if (sector === '公告') return 'orange';
if (sector === '其他') return 'gray';
@@ -104,6 +91,15 @@ const SectorDetails = ({ sortedSectors, totalStocks }) => {
<Flex justify="space-between" align="center">
<HStack spacing={3}>
<Heading size="md">板块详情</Heading>
<Button
size="sm"
variant="outline"
colorScheme="whiteAlpha"
onClick={toggleAllSectors}
leftIcon={expandedSectors.length === sortedSectors.length ? <ChevronUpIcon /> : <ChevronDownIcon />}
>
{expandedSectors.length === sortedSectors.length ? '全部收起' : '全部展开'}
</Button>
</HStack>
<HStack spacing={2}>
<Badge bg="whiteAlpha.900" color="blue.500" fontSize="md" px={3}>
@@ -194,6 +190,8 @@ const SectorDetails = ({ sortedSectors, totalStocks }) => {
bg: 'gray.50'
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onStockClick(stock)}
>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
@@ -211,21 +209,9 @@ const SectorDetails = ({ sortedSectors, totalStocks }) => {
)}
</HStack>
<Collapse in={expandedStockReasons[stock.scode]}>
<Box mt={2} p={3} bg="gray.50" borderRadius="md" border="1px solid" borderColor="gray.200">
<Text fontSize="sm" color="gray.700" fontWeight="bold">
涨停原因:
</Text>
<Text
fontSize="sm"
color="gray.600"
noOfLines={3}
{...getFormattedTextProps(stock.brief || stock.summary || '暂无涨停原因').props}
>
{getFormattedTextProps(stock.brief || stock.summary || '暂无涨停原因').children}
</Text>
</Box>
</Collapse>
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{stock.brief || stock.summary || '暂无涨停原因'}
</Text>
<HStack spacing={4} fontSize="xs" color="gray.500">
<HStack spacing={1}>
@@ -269,12 +255,11 @@ const SectorDetails = ({ sortedSectors, totalStocks }) => {
</VStack>
<IconButton
icon={expandedStockReasons[stock.scode] ? <ChevronUpIcon /> : <ChevronDownIcon />}
icon={<ViewIcon />}
size="sm"
variant="ghost"
colorScheme={colorScheme}
aria-label={expandedStockReasons[stock.scode] ? "收起原因" : "展开原因"}
onClick={() => toggleStockReason(stock.scode)}
aria-label="查看详情"
/>
</Flex>
</Box>

View File

@@ -21,9 +21,6 @@ import {
StatNumber,
StatHelpText,
StatArrow,
Alert,
AlertIcon,
Link,
} from '@chakra-ui/react';
import {
RepeatIcon,
@@ -35,29 +32,24 @@ import {
// 这里为了演示,我们假设它们已经被正确导出
// API配置
const API_URL = process.env.NODE_ENV === 'production' ? '/report-api' : 'http://111.198.58.126:5001';
const API_URL = process.env.NODE_ENV === 'production' ? '/report-api' : 'http://111.198.58.126:8811';
// 导入的组件(实际使用时应该从独立文件导入)
// 恢复使用本页自带的轻量日历
import EnhancedCalendar from './components/EnhancedCalendar';
import SectorDetails from './components/SectorDetails';
import { DataAnalysis, StockDetailModal } from './components/DataVisualizationComponents';
import { AdvancedSearch, SearchResultsModal } from './components/SearchComponents';
// 导入导航栏组件
import HomeNavbar from '../../components/Navbars/HomeNavbar';
// 导入高位股统计组件
import HighPositionStocks from './components/HighPositionStocks';
// 主组件
export default function LimitAnalyse() {
const [selectedDate, setSelectedDate] = useState(null);
const [selectedDate, setSelectedDate] = useState(new Date());
const [dateStr, setDateStr] = useState('');
const [loading, setLoading] = useState(false);
const [dailyData, setDailyData] = useState(null);
const [availableDates, setAvailableDates] = useState([]);
const [selectedStock, setSelectedStock] = useState(null);
const [wordCloudData, setWordCloudData] = useState([]);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [searchResults, setSearchResults] = useState(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
@@ -72,37 +64,14 @@ export default function LimitAnalyse() {
fetchAvailableDates();
}, []);
// 初始进入展示骨架屏,直到选中日期的数据加载完成
// 加载初始数据
useEffect(() => {
setLoading(true);
const today = new Date();
const dateString = formatDateStr(today);
setDateStr(dateString);
fetchDailyAnalysis(dateString);
}, []);
// 根据可用日期加载最近一个有数据的日期
useEffect(() => {
if (availableDates && availableDates.length > 0) {
// 选择日期字符串最大的那一天(格式为 YYYYMMDD
const latest = availableDates.reduce((max, cur) =>
(!max || (cur.date && cur.date > max)) ? cur.date : max
, null);
if (latest) {
setDateStr(latest);
const year = parseInt(latest.slice(0, 4), 10);
const month = parseInt(latest.slice(4, 6), 10) - 1;
const day = parseInt(latest.slice(6, 8), 10);
setSelectedDate(new Date(year, month, day));
fetchDailyAnalysis(latest);
}
} else {
// 如果暂无可用日期,回退到今日,避免页面长时间空白
const today = new Date();
const dateString = formatDateStr(today);
setDateStr(dateString);
setSelectedDate(today);
fetchDailyAnalysis(dateString);
}
}, [availableDates]);
// API调用函数
const fetchAvailableDates = async () => {
try {
@@ -209,6 +178,12 @@ export default function LimitAnalyse() {
}
};
// 处理股票详情
const handleStockDetail = (stock) => {
setSelectedStock(stock);
setIsDetailOpen(true);
};
// 处理板块数据排序
const getSortedSectorData = () => {
if (!dailyData?.sector_data) return [];
@@ -286,130 +261,35 @@ export default function LimitAnalyse() {
</SimpleGrid>
);
const formatDisplayDate = (date) => {
if (!date) return '';
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}${month}${day}`;
};
const getSelectedDateCount = () => {
if (!selectedDate || !availableDates?.length) return null;
const date = formatDateStr(selectedDate);
const found = availableDates.find(d => d.date === date);
return found ? found.count : null;
};
return (
<Box minH="100vh" bg={bgColor}>
{/* 导航栏 */}
<HomeNavbar />
{/* 顶部Header */}
<Box bgGradient="linear(to-br, blue.500, purple.600)" color="white" py={8}>
<Container maxW="container.xl">
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6} alignItems="stretch">
{/* 左侧:标题置顶,注释与图例贴底 */}
<Flex direction="column" minH="420px" justify="space-between">
<VStack align="start" spacing={4}>
<HStack>
<Badge colorScheme="whiteAlpha" fontSize="sm" px={2}>
AI驱动
</Badge>
<Badge colorScheme="yellow" fontSize="sm" px={2}>
实时更新
</Badge>
</HStack>
<Heading
size="3xl"
fontWeight="extrabold"
letterSpacing="-0.5px"
lineHeight="shorter"
textShadow="0 6px 24px rgba(0,0,0,0.25)"
>
涨停板块分析平台
</Heading>
<Text fontSize="xl" opacity={0.98} fontWeight="semibold" textShadow="0 4px 16px rgba(0,0,0,0.2)">
以大模型辅助整理海量信息结合领域知识图谱与分析师复核呈现涨停板块关键线索
</Text>
</VStack>
<Flex justify="space-between" align="center" wrap="wrap" gap={6}>
<VStack align="start" spacing={2}>
<HStack>
<Badge colorScheme="whiteAlpha" fontSize="sm" px={2}>
AI驱动
</Badge>
<Badge colorScheme="yellow" fontSize="sm" px={2}>
实时更新
</Badge>
</HStack>
<Heading size="xl">涨停板块分析平台</Heading>
<Text fontSize="lg" opacity={0.9}>
智能分析每日涨停板块精准捕捉市场热点
</Text>
</VStack>
<VStack align="stretch" spacing={3}>
<Alert
status="info"
borderRadius="xl"
bg="whiteAlpha.200"
color="whiteAlpha.900"
borderWidth="1px"
borderColor="whiteAlpha.300"
backdropFilter="saturate(180%) blur(10px)"
boxShadow="0 8px 32px rgba(0,0,0,0.2)"
>
<AlertIcon />
<Text fontSize="md" fontWeight="medium">
{selectedDate ? `当前选择:${formatDisplayDate(selectedDate)}` : '当前选择:--'}
{getSelectedDateCount() != null ? ` - ${getSelectedDateCount()}只涨停` : ''}
</Text>
</Alert>
<Card
bg="whiteAlpha.200"
color="whiteAlpha.900"
borderRadius="xl"
boxShadow="0 8px 32px rgba(0,0,0,0.2)"
borderWidth="1px"
borderColor="whiteAlpha.300"
backdropFilter="saturate(180%) blur(10px)"
w="full"
>
<CardBody>
<VStack align="stretch" spacing={2}>
<Heading size="sm" color="whiteAlpha.900">涨停数量图例</Heading>
<VStack align="stretch" spacing={2}>
<HStack>
<Box w={5} h={5} bg="green.200" borderRadius="md" border="1px solid" borderColor="whiteAlpha.400" />
<Text fontSize="sm">少量 (50)</Text>
</HStack>
<HStack>
<Box w={5} h={5} bg="yellow.200" borderRadius="md" border="1px solid" borderColor="whiteAlpha.400" />
<Text fontSize="sm">中等 (51-80)</Text>
</HStack>
<HStack>
<Box w={5} h={5} bg="red.200" borderRadius="md" border="1px solid" borderColor="whiteAlpha.400" />
<Text fontSize="sm">大量 (&gt;80)</Text>
</HStack>
</VStack>
</VStack>
</CardBody>
</Card>
</VStack>
</Flex>
{/* 右侧:半屏日历 */}
<Card
bg="whiteAlpha.200"
borderRadius="xl"
boxShadow="0 8px 32px rgba(0,0,0,0.2)"
borderWidth="1px"
borderColor="whiteAlpha.300"
backdropFilter="saturate(180%) blur(10px)"
w="full"
minH="420px"
>
<CardBody p={4}>
<EnhancedCalendar
selectedDate={selectedDate}
onDateChange={handleDateChange}
availableDates={availableDates}
compact
hideSelectionInfo
width="100%"
cellHeight={10}
/>
</CardBody>
</Card>
</SimpleGrid>
<Box mt={{ base: 4, md: 0 }}>
<EnhancedCalendar
selectedDate={selectedDate}
onDateChange={handleDateChange}
availableDates={availableDates}
/>
</Box>
</Flex>
</Container>
</Box>
@@ -437,13 +317,11 @@ export default function LimitAnalyse() {
<SectorDetails
sortedSectors={getSortedSectorData()}
totalStocks={dailyData?.total_stocks || 0}
onStockClick={handleStockDetail}
/>
</Box>
)}
{/* 高位股统计 */}
<HighPositionStocks dateStr={dateStr} />
{/* 数据分析 */}
{loading ? (
<Skeleton height="500px" borderRadius="xl" />
@@ -456,11 +334,17 @@ export default function LimitAnalyse() {
</Container>
{/* 弹窗 */}
<StockDetailModal
isOpen={isDetailOpen}
onClose={() => setIsDetailOpen(false)}
selectedStock={selectedStock}
/>
<SearchResultsModal
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
searchResults={searchResults}
onStockClick={() => {}}
onStockClick={handleStockDetail}
/>
{/* 浮动按钮 */}
@@ -489,27 +373,6 @@ export default function LimitAnalyse() {
</Tooltip>
</VStack>
</Box>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}

View File

@@ -1,3 +1,21 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import {
Box,
Button,
@@ -21,13 +39,11 @@ import BillingRow from 'components/Tables/BillingRow';
import InvoicesRow from 'components/Tables/InvoicesRow';
import TransactionRow from 'components/Tables/TransactionRow';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
FaPaypal,
FaPencilAlt,
FaRegCalendarAlt,
FaWallet,
FaGem,
} from 'react-icons/fa';
import { RiMastercardFill } from 'react-icons/ri';
import {
@@ -43,7 +59,6 @@ function Billing() {
const textColor = useColorModeValue('gray.700', 'white');
const borderColor = useColorModeValue('#dee2e6', 'transparent');
const { colorMode } = useColorMode();
const navigate = useNavigate();
return (
<Flex direction='column' pt={{ base: '120px', md: '75px' }}>
@@ -152,8 +167,8 @@ function Billing() {
w='100%'
py='14px'
>
<IconBox h={'60px'} w={'60px'} bg='purple.500'>
<Icon h={'24px'} w={'24px'} color='white' as={FaGem} />
<IconBox h={'60px'} w={'60px'} bg={iconBlue}>
<Icon h={'24px'} w={'24px'} color='white' as={FaPaypal} />
</IconBox>
<Flex
direction='column'
@@ -164,7 +179,7 @@ function Billing() {
w='100%'
>
<Text fontSize='md' color={textColor} fontWeight='bold'>
订阅服务
Paypal
</Text>
<Text
mb='24px'
@@ -172,17 +187,13 @@ function Billing() {
color='gray.400'
fontWeight='semibold'
>
Pro & Max 版本
Freelance Payment
</Text>
<HSeparator />
</Flex>
<Button
size='sm'
colorScheme='purple'
onClick={() => navigate('/home/pages/account/subscription')}
>
管理订阅
</Button>
<Text fontSize='lg' color={textColor} fontWeight='bold'>
$455.00
</Text>
</Flex>
</Card>
</Grid>

View File

@@ -569,15 +569,6 @@ export default function ProfilePage() {
</HStack>
)}
<HStack justify="space-between" w="full">
<Text fontSize="sm" color="gray.600">微信</Text>
{user?.has_wechat ? (
<Badge size="xs" colorScheme="green">已绑定</Badge>
) : (
<Badge size="xs" colorScheme="gray">未绑定</Badge>
)}
</HStack>
<HStack justify="space-between" w="full">
<Text fontSize="sm" color="gray.600">注册时间</Text>
<Text fontSize="sm">

View File

@@ -1,6 +1,5 @@
// src/views/Settings/SettingsPage.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
@@ -44,12 +43,7 @@ import {
InputRightElement,
PinInput,
PinInputField,
SimpleGrid,
Checkbox,
UnorderedList,
ListItem,
OrderedList,
Spacer
SimpleGrid
} from '@chakra-ui/react';
import {
EditIcon,
@@ -68,13 +62,11 @@ export default function SettingsPage() {
const { user, updateUser, logout } = useAuth();
const { colorMode, toggleColorMode } = useColorMode();
const toast = useToast();
const navigate = useNavigate();
// 模态框状态
const { isOpen: isPasswordOpen, onOpen: onPasswordOpen, onClose: onPasswordClose } = useDisclosure();
const { isOpen: isPhoneOpen, onOpen: onPhoneOpen, onClose: onPhoneClose } = useDisclosure();
const { isOpen: isEmailOpen, onOpen: onEmailOpen, onClose: onEmailClose } = useDisclosure();
const { isOpen: isNoticeOpen, onOpen: onNoticeOpen, onClose: onNoticeClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
// 表单状态
@@ -85,14 +77,6 @@ export default function SettingsPage() {
newPassword: '',
confirmPassword: ''
});
// 密码状态 - 默认假设是普通用户,获取到数据后再更新
const [passwordStatus, setPasswordStatus] = useState({
isWechatUser: false,
hasPassword: true,
needsFirstTimeSetup: false
});
const [passwordStatusLoading, setPasswordStatusLoading] = useState(true);
const [phoneForm, setPhoneForm] = useState({
phone: '',
verificationCode: ''
@@ -101,11 +85,6 @@ export default function SettingsPage() {
email: '',
verificationCode: ''
});
// 注销相关状态
const [hasAgreedToNotice, setHasAgreedToNotice] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [blockedKeywords, setBlockedKeywords] = useState(user?.blocked_keywords || '');
// 通知设置状态
@@ -127,53 +106,8 @@ export default function SettingsPage() {
allow_friend_requests: true
});
// 获取密码状态
const fetchPasswordStatus = async () => {
try {
const API_BASE_URL = process.env.NODE_ENV === 'production'
? ""
: process.env.REACT_APP_API_URL || "http://49.232.185.254:5000";
const response = await fetch(`${API_BASE_URL}/api/account/password-status`, {
method: 'GET',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
console.log('密码状态数据:', data.data); // 调试信息
setPasswordStatus(data.data);
}
}
} catch (error) {
console.error('获取密码状态失败:', error);
} finally {
setPasswordStatusLoading(false);
}
};
// 组件加载时获取密码状态
React.useEffect(() => {
fetchPasswordStatus();
}, []);
// 修改密码
const handlePasswordChange = async () => {
const isFirstTimeSetup = passwordStatus.needsFirstTimeSetup;
// 如果不是首次设置且未提供当前密码
if (!isFirstTimeSetup && !passwordForm.currentPassword) {
toast({
title: "请输入当前密码",
description: "修改密码需要验证当前密码",
status: "error",
duration: 3000,
isClosable: true,
});
return;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast({
title: "密码不匹配",
@@ -198,52 +132,24 @@ export default function SettingsPage() {
setIsLoading(true);
try {
// 调用后端API修改密码
const API_BASE_URL = process.env.NODE_ENV === 'production'
? ""
: process.env.REACT_APP_API_URL || "http://49.232.185.254:5000";
const response = await fetch(`${API_BASE_URL}/api/account/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 包含认证信息
body: JSON.stringify({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
isFirstSet: passwordStatus.needsFirstTimeSetup
})
// 这里应该调用后端API修改密码
await new Promise(resolve => setTimeout(resolve, 1000));
toast({
title: "密码修改成功",
description: "请重新登录",
status: "success",
duration: 3000,
isClosable: true,
});
const data = await response.json();
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
onPasswordClose();
if (response.ok && data.success) {
const isFirstSet = passwordStatus.needsFirstTimeSetup;
toast({
title: isFirstSet ? "密码设置成功" : "密码修改成功",
description: isFirstSet ? "您现在可以使用手机号+密码登录了" : "请重新登录",
status: "success",
duration: 3000,
isClosable: true,
});
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
onPasswordClose();
// 刷新密码状态
fetchPasswordStatus();
// 如果是修改密码(非首次设置),需要重新登录
if (!isFirstSet) {
setTimeout(() => {
logout();
}, 1000);
}
} else {
throw new Error(data.error || '密码修改失败');
}
// 修改密码后需要重新登录
setTimeout(() => {
logout();
}, 1000);
} catch (error) {
toast({
title: "修改失败",
@@ -261,26 +167,8 @@ export default function SettingsPage() {
const sendVerificationCode = async (type) => {
setIsLoading(true);
try {
if (type === 'phone') {
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/phone/send-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone: phoneForm.phone })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '发送失败');
} else {
// 使用绑定邮箱的验证码API
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/email/send-bind-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: emailForm.email })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '发送失败');
}
// 这里应该调用后端API发送验证码
await new Promise(resolve => setTimeout(resolve, 1000));
toast({
title: "验证码已发送",
@@ -306,14 +194,8 @@ export default function SettingsPage() {
const handlePhoneBind = async () => {
setIsLoading(true);
try {
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/phone/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone: phoneForm.phone, code: phoneForm.verificationCode })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '绑定失败');
// 这里应该调用后端API绑定手机号
await new Promise(resolve => setTimeout(resolve, 1000));
updateUser({
phone: phoneForm.phone,
@@ -346,30 +228,16 @@ export default function SettingsPage() {
const handleEmailBind = async () => {
setIsLoading(true);
try {
// 调用真实的邮箱绑定API
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/email/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: emailForm.email,
code: emailForm.verificationCode
})
});
// 这里应该调用后端API更换邮箱
await new Promise(resolve => setTimeout(resolve, 1000));
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || '绑定失败');
}
// 更新用户信息
updateUser({
email: data.user.email,
email_confirmed: data.user.email_confirmed
email: emailForm.email,
email_confirmed: true
});
toast({
title: "邮箱绑定成功",
title: "邮箱更换成功",
status: "success",
duration: 3000,
isClosable: true,
@@ -379,7 +247,7 @@ export default function SettingsPage() {
onEmailClose();
} catch (error) {
toast({
title: "绑定失败",
title: "更换失败",
description: error.message,
status: "error",
duration: 3000,
@@ -449,41 +317,8 @@ export default function SettingsPage() {
}
};
// 打开注销须知弹窗
const handleOpenCancellationNotice = () => {
setHasAgreedToNotice(false);
onNoticeOpen();
};
// 从须知弹窗进入确认注销流程
const handleProceedToDelete = () => {
if (!hasAgreedToNotice) {
toast({
title: "请先阅读并同意注销须知",
status: "warning",
duration: 3000,
isClosable: true,
});
return;
}
onNoticeClose();
setDeleteConfirmText('');
onDeleteOpen();
};
// 注销账户
const handleDeleteAccount = async () => {
if (deleteConfirmText !== '确认注销') {
toast({
title: "请输入正确的确认文本",
description: "请输入'确认注销'来确认操作",
status: "warning",
duration: 3000,
isClosable: true,
});
return;
}
setIsLoading(true);
try {
// 这里应该调用后端API注销账户
@@ -538,27 +373,13 @@ export default function SettingsPage() {
<CardBody>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<Text fontWeight="medium">
{passwordStatus.needsFirstTimeSetup ? '设置登录密码' : '登录密码'}
</Text>
<Text fontWeight="medium">登录密码</Text>
<Text fontSize="sm" color="gray.600">
{passwordStatus.needsFirstTimeSetup
? '您通过微信登录,建议设置密码以便其他方式登录'
: '定期更换密码,保护账户安全'
}
定期更换密码保护账户安全
</Text>
{passwordStatus.isWechatUser && (
<Text fontSize="xs" color="blue.500">
微信用户
</Text>
)}
</VStack>
<Button
leftIcon={<EditIcon />}
onClick={onPasswordOpen}
isLoading={passwordStatusLoading}
>
{passwordStatus.needsFirstTimeSetup ? '设置密码' : '修改密码'}
<Button leftIcon={<EditIcon />} onClick={onPasswordOpen}>
修改密码
</Button>
</HStack>
</CardBody>
@@ -590,23 +411,7 @@ export default function SettingsPage() {
leftIcon={<DeleteIcon />}
colorScheme="red"
variant="outline"
onClick={async () => {
setIsLoading(true);
try {
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/phone/unbind', {
method: 'POST',
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '解绑失败');
updateUser({ phone: null, phone_confirmed: false });
toast({ title: '手机号解绑成功', status: 'success', duration: 3000, isClosable: true });
} catch (e) {
toast({ title: '解绑失败', description: e.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setIsLoading(false);
}
}}
onClick={handlePhoneUnbind}
isLoading={isLoading}
>
解绑
@@ -672,64 +477,11 @@ export default function SettingsPage() {
leftIcon={<DeleteIcon />}
colorScheme="red"
variant="outline"
onClick={async () => {
setIsLoading(true);
try {
const res = await fetch((process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001') + '/api/account/wechat/unbind', {
method: 'POST',
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '解绑失败');
updateUser({ has_wechat: false });
toast({ title: '解绑成功', status: 'success', duration: 3000, isClosable: true });
} catch (e) {
toast({ title: '解绑失败', description: e.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setIsLoading(false);
}
}}
>
解绑微信
</Button>
) : (
<Button leftIcon={<LinkIcon />} colorScheme="green" onClick={async () => {
setIsLoading(true);
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const res = await fetch(base + '/api/account/wechat/qrcode', { method: 'GET', credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || '获取二维码失败');
// 打开新的窗口进行扫码
window.open(data.auth_url, '_blank');
// 轮询绑定状态
const sessionId = data.session_id;
const start = Date.now();
const poll = async () => {
if (Date.now() - start > 300000) return; // 5分钟
const r = await fetch(base + '/api/account/wechat/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ session_id: sessionId })
});
const j = await r.json();
if (j.status === 'bind_ready') {
updateUser({ has_wechat: true });
toast({ title: '微信绑定成功', status: 'success', duration: 3000, isClosable: true });
} else if (j.status === 'bind_conflict' || j.status === 'bind_failed' || j.status === 'expired') {
toast({ title: '绑定未完成', description: j.status, status: 'error', duration: 3000, isClosable: true });
} else {
setTimeout(poll, 2000);
}
};
setTimeout(poll, 1500);
} catch (e) {
toast({ title: '获取二维码失败', description: e.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setIsLoading(false);
}
}}>
<Button leftIcon={<LinkIcon />} colorScheme="green">
绑定微信
</Button>
)}
@@ -1087,7 +839,7 @@ export default function SettingsPage() {
<Button
colorScheme="red"
leftIcon={<WarningIcon />}
onClick={handleOpenCancellationNotice}
onClick={onDeleteOpen}
maxW="200px"
>
注销账户
@@ -1105,49 +857,30 @@ export default function SettingsPage() {
<Modal isOpen={isPasswordOpen} onClose={onPasswordClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{passwordStatus.needsFirstTimeSetup ? '设置登录密码' : '修改密码'}
</ModalHeader>
<ModalHeader>修改密码</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
{/* 微信用户说明 */}
{passwordStatus.isWechatUser && passwordStatus.needsFirstTimeSetup && (
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>设置密码以便多种方式登录</AlertTitle>
<AlertDescription fontSize="sm">
您当前通过微信登录设置密码后可以使用手机号+密码的方式登录
</AlertDescription>
</Box>
</Alert>
)}
{/* 当前密码 - 仅非首次设置且非加载状态时显示 */}
{!passwordStatusLoading && !passwordStatus.needsFirstTimeSetup && (
<FormControl>
<FormLabel>当前密码</FormLabel>
<InputGroup>
<Input
type={showPassword ? "text" : "password"}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm(prev => ({
...prev,
currentPassword: e.target.value
}))}
placeholder="请输入当前密码"
<FormControl>
<FormLabel>当前密码</FormLabel>
<InputGroup>
<Input
type={showPassword ? "text" : "password"}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm(prev => ({
...prev,
currentPassword: e.target.value
}))}
/>
<InputRightElement>
<IconButton
variant="ghost"
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
/>
<InputRightElement>
<IconButton
variant="ghost"
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
</FormControl>
)}
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl>
<FormLabel>新密码</FormLabel>
@@ -1183,7 +916,7 @@ export default function SettingsPage() {
onClick={handlePasswordChange}
isLoading={isLoading}
>
{passwordStatus.needsFirstTimeSetup ? '设置密码' : '确认修改'}
确认修改
</Button>
</ModalFooter>
</ModalContent>
@@ -1320,124 +1053,6 @@ export default function SettingsPage() {
</ModalContent>
</Modal>
{/* 账户注销须知模态框 */}
<Modal
isOpen={isNoticeOpen}
onClose={onNoticeClose}
size="xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader color="red.600" borderBottom="1px" borderColor="gray.200">
价值前沿账户注销须知
</ModalHeader>
<ModalCloseButton />
<ModalBody py={6}>
<VStack spacing={6} align="stretch">
<Alert status="warning" borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>特别提示</AlertTitle>
<AlertDescription>
当您提出申请注销即表示您已充分阅读理解并接受本注销须知的全部内容
您注销账户的行为会给您的售后维权带来诸多不便且注销价值前沿账户后
您的个人信息我们会在价值前沿系统中去除使其保持不可被检索访问的状态
</AlertDescription>
</Box>
</Alert>
<Text fontSize="sm" color="gray.700">
亲爱的用户您在申请注销前应当认真阅读价值前沿账户注销须知以下称"《注销须知》"
请您务必审慎阅读充分理解注销须知中相关条款内容其中包括
</Text>
<Box>
<Text fontWeight="medium" mb={2}>如果您仍执意注销账户您的账户需同时满足以下条件</Text>
<OrderedList spacing={2} fontSize="sm" color="gray.700">
<ListItem>
<Text as="span" fontWeight="medium">自愿放弃账户在价值前沿系统中的资产和虚拟权益</Text>
<Text fontSize="xs" color="gray.600">包括但不限于账户余额VIP权益付费工具使用权限等</Text>
</ListItem>
<ListItem>账户当前为有效账户非冻结状态</ListItem>
</OrderedList>
</Box>
<Box>
<Text fontWeight="medium" mb={2} color="red.600">注销后您将失去的权益和数据</Text>
<UnorderedList spacing={1} fontSize="sm" color="gray.700">
<ListItem>无法登录使用本价值前沿账户</ListItem>
<ListItem>个人资料和历史信息用户名头像购买记录关注信息等将无法找回</ListItem>
<ListItem>通过价值前沿账户使用的所有记录将无法找回</ListItem>
<ListItem>曾获得的余额优惠券积分权益订单等视为自行放弃</ListItem>
<ListItem>无法继续使用相关服务价值前沿无法协助您重新恢复</ListItem>
</UnorderedList>
</Box>
<Alert status="error" borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>重要警告</AlertTitle>
<AlertDescription fontSize="sm">
<Text>价值前沿账户一旦被注销将不可恢复请您在操作之前自行备份相关的所有信息和数据</Text>
</AlertDescription>
</Box>
</Alert>
<Box>
<Text fontWeight="medium" mb={2}>其他重要条款</Text>
<UnorderedList spacing={2} fontSize="sm" color="gray.700">
<ListItem>
在价值前沿账户注销期间如果您的账户涉及争议纠纷包括但不限于投诉举报诉讼仲裁国家有权机关调查等
价值前沿有权自行终止本账户的注销而无需另行得到您的同意
</ListItem>
<ListItem>
<Text as="span" fontWeight="medium">注销时效</Text>
在注销账号后除非根据法律法规或监管部门要求保留或存储您的个人信息
否则账号注销处理时效为即时我们将即时删除您的个人信息
</ListItem>
<ListItem>
<Text as="span" fontWeight="medium" color="red.600">免责声明</Text>
注销本价值前沿账户并不代表账户注销前的账户行为和相关责任得到豁免或减轻
</ListItem>
</UnorderedList>
</Box>
<Divider />
<VStack spacing={4}>
<Text fontSize="sm" color="gray.600" textAlign="center">
如您对本注销须知有任何疑问可联系在线客服查询
</Text>
<Checkbox
isChecked={hasAgreedToNotice}
onChange={(e) => setHasAgreedToNotice(e.target.checked)}
colorScheme="red"
size="lg"
>
<Text fontSize="sm" fontWeight="medium">
我已仔细阅读并充分理解上述内容同意按照价值前沿账户注销须知的条款进行账户注销
</Text>
</Checkbox>
</VStack>
</VStack>
</ModalBody>
<ModalFooter borderTop="1px" borderColor="gray.200">
<Button variant="ghost" mr={3} onClick={onNoticeClose}>
取消
</Button>
<Button
colorScheme="red"
onClick={handleProceedToDelete}
isDisabled={!hasAgreedToNotice}
>
我已同意继续注销
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 注销账户确认模态框 */}
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}>
<ModalOverlay />
@@ -1460,11 +1075,7 @@ export default function SettingsPage() {
如果您确定要注销账户请在下方输入 "确认注销" 来确认此操作
</Text>
<Input
placeholder="请输入:确认注销"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
/>
<Input placeholder="请输入:确认注销" />
</VStack>
</ModalBody>
<ModalFooter>