update pay promo

This commit is contained in:
2026-02-03 15:32:52 +08:00
parent 85fd94b676
commit b57cd3019c
3 changed files with 233 additions and 121 deletions

View File

@@ -1,7 +1,8 @@
// src/components/Navbars/SearchBar/SearchBar.js // src/components/Navbars/SearchBar/SearchBar.js
// 全局股票搜索栏 - 模糊搜索 + 下拉选择 // 全局股票搜索栏 - 模糊搜索 + 下拉选择
// 设计风格:玻璃态融合 + 微妙金色点缀
import React, { useRef, useEffect, useCallback } from "react"; import React, { useRef, useEffect, useCallback, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Box, Box,
@@ -27,16 +28,14 @@ export function SearchBar(props) {
const { variant, children, ...rest } = props; const { variant, children, ...rest } = props;
const navigate = useNavigate(); const navigate = useNavigate();
const containerRef = useRef(null); const containerRef = useRef(null);
const [isFocused, setIsFocused] = useState(false);
// 颜色配置 - 固定使用深色主题(增强可见性) // 颜色配置 - 玻璃态风格,低调但醒目
const searchIconColor = "#D4AF37"; const accentColor = "#D4AF37"; // 金色强调
const inputBg = "rgba(26, 32, 44, 0.9)"; const dropdownBg = "rgba(20, 25, 35, 0.98)";
const dropdownBg = "#1a1a2e";
const borderColor = "rgba(212, 175, 55, 0.5)";
const hoverBg = "whiteAlpha.100"; const hoverBg = "whiteAlpha.100";
const textColor = "white"; const textColor = "white";
const subTextColor = "whiteAlpha.600"; const subTextColor = "whiteAlpha.600";
const accentColor = "#D4AF37";
// 使用搜索 Hook // 使用搜索 Hook
const { const {
@@ -54,6 +53,7 @@ export function SearchBar(props) {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) { if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowResults(false); setShowResults(false);
setIsFocused(false);
} }
}; };
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
@@ -63,6 +63,7 @@ export function SearchBar(props) {
// 选择股票 - 跳转到详情页 // 选择股票 - 跳转到详情页
const handleSelectStock = useCallback((stock) => { const handleSelectStock = useCallback((stock) => {
clearSearch(); clearSearch();
setIsFocused(false);
// 跳转到股票详情页 // 跳转到股票详情页
navigate(`/company/${stock.stock_code}`); navigate(`/company/${stock.stock_code}`);
}, [navigate, clearSearch]); }, [navigate, clearSearch]);
@@ -73,95 +74,148 @@ export function SearchBar(props) {
handleSelectStock(searchResults[0]); handleSelectStock(searchResults[0]);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setShowResults(false); setShowResults(false);
setIsFocused(false);
} }
}, [searchResults, handleSelectStock, setShowResults]); }, [searchResults, handleSelectStock, setShowResults]);
// 处理焦点
const handleFocus = () => {
setIsFocused(true);
if (searchQuery && searchResults.length > 0) {
setShowResults(true);
}
};
const handleBlur = () => {
// 延迟关闭,让点击事件先执行
setTimeout(() => {
if (!containerRef.current?.contains(document.activeElement)) {
setIsFocused(false);
}
}, 150);
};
return ( return (
<Box ref={containerRef} position="relative" {...rest}> <Box ref={containerRef} position="relative" {...rest}>
<InputGroup borderRadius="8px" w={{ base: "180px", md: "220px", lg: "280px" }}> {/* 搜索框容器 - 带微妙动画 */}
<InputLeftElement pointerEvents="none"> <Box
<Search color={searchIconColor} size={16} /> position="relative"
</InputLeftElement> borderRadius="full"
<Input bg={isFocused ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.06)"}
variant="search" backdropFilter="blur(10px)"
fontSize="sm" border="1px solid"
bg={inputBg} borderColor={isFocused ? "rgba(212, 175, 55, 0.4)" : "rgba(255, 255, 255, 0.1)"}
color="white" boxShadow={isFocused ? "0 0 20px rgba(212, 175, 55, 0.15)" : "none"}
placeholder="搜索股票代码/名称..." transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
value={searchQuery} _hover={{
onChange={(e) => handleSearch(e.target.value)} bg: "rgba(255, 255, 255, 0.1)",
onKeyDown={handleKeyDown} borderColor: "rgba(212, 175, 55, 0.3)",
onFocus={() => searchQuery && searchResults.length > 0 && setShowResults(true)} }}
border="1px solid" >
borderColor={borderColor} <InputGroup w={{ base: "160px", md: "200px", lg: "240px" }}>
_placeholder={{ color: "gray.400" }} <InputLeftElement pointerEvents="none" h="full">
_hover={{ borderColor: accentColor, bg: "rgba(26, 32, 44, 1)" }} <Search
_focus={{ size={15}
borderColor: accentColor, color={isFocused ? accentColor : "rgba(255, 255, 255, 0.5)"}
boxShadow: `0 0 0 1px ${accentColor}`, style={{ transition: "color 0.3s" }}
bg: "rgba(26, 32, 44, 1)" />
}} </InputLeftElement>
/> <Input
{(searchQuery || isSearching) && ( variant="unstyled"
<InputRightElement> fontSize="sm"
{isSearching ? ( color="white"
<Spinner size="sm" color={accentColor} /> placeholder="搜索股票..."
) : ( value={searchQuery}
<IconButton onChange={(e) => handleSearch(e.target.value)}
size="xs" onKeyDown={handleKeyDown}
variant="ghost" onFocus={handleFocus}
icon={<X size={10} />} onBlur={handleBlur}
onClick={clearSearch} pl={10}
aria-label="清除搜索" pr={searchQuery ? 10 : 4}
_hover={{ bg: "transparent" }} py={2}
/> h="36px"
)} _placeholder={{
</InputRightElement> color: "rgba(255, 255, 255, 0.4)",
)} fontSize: "sm",
</InputGroup> }}
/>
{(searchQuery || isSearching) && (
<InputRightElement h="full">
{isSearching ? (
<Spinner size="xs" color={accentColor} />
) : (
<IconButton
size="xs"
variant="ghost"
icon={<X size={12} color="rgba(255, 255, 255, 0.5)" />}
onClick={clearSearch}
aria-label="清除搜索"
borderRadius="full"
minW="auto"
h="20px"
w="20px"
_hover={{ bg: "whiteAlpha.200" }}
/>
)}
</InputRightElement>
)}
</InputGroup>
</Box>
{/* 搜索结果下拉 */} {/* 搜索结果下拉 - 玻璃态设计 */}
{showResults && ( {showResults && (
<Box <Box
position="absolute" position="absolute"
top="100%" top="calc(100% + 8px)"
left={0} left="50%"
mt={2} transform="translateX(-50%)"
w="320px" w="320px"
bg={dropdownBg} bg={dropdownBg}
backdropFilter="blur(20px)"
border="1px solid" border="1px solid"
borderColor={borderColor} borderColor="rgba(212, 175, 55, 0.2)"
borderRadius="md" borderRadius="xl"
boxShadow="lg" boxShadow="0 10px 40px rgba(0, 0, 0, 0.4), 0 0 20px rgba(212, 175, 55, 0.1)"
maxH="400px" maxH="400px"
overflowY="auto" overflowY="auto"
zIndex={9999} zIndex={9999}
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(212, 175, 55, 0.3)',
borderRadius: '2px',
},
}}
> >
{searchResults.length > 0 ? ( {searchResults.length > 0 ? (
<List spacing={0}> <List spacing={0} py={2}>
{searchResults.map((stock, index) => ( {searchResults.map((stock, index) => (
<ListItem <ListItem
key={stock.stock_code} key={stock.stock_code}
px={4} px={4}
py={3} py={2.5}
mx={2}
cursor="pointer" cursor="pointer"
_hover={{ bg: hoverBg }} borderRadius="lg"
transition="all 0.2s"
_hover={{
bg: "rgba(212, 175, 55, 0.1)",
}}
onClick={() => handleSelectStock(stock)} onClick={() => handleSelectStock(stock)}
borderBottomWidth={index < searchResults.length - 1 ? "1px" : "0"}
borderColor={borderColor}
> >
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<VStack align="start" spacing={0} flex={1}> <VStack align="start" spacing={0} flex={1}>
<Text fontWeight="bold" color={textColor} fontSize="sm"> <Text fontWeight="semibold" color={textColor} fontSize="sm">
{stock.stock_name} {stock.stock_name}
</Text> </Text>
<HStack spacing={2}> <HStack spacing={2}>
<Text fontSize="xs" color={subTextColor}> <Text fontSize="xs" color={accentColor} fontFamily="mono">
{stock.stock_code} {stock.stock_code}
</Text> </Text>
{stock.pinyin_abbr && ( {stock.pinyin_abbr && (
<Text fontSize="xs" color={subTextColor}> <Text fontSize="xs" color={subTextColor}>
({stock.pinyin_abbr.toUpperCase()}) {stock.pinyin_abbr.toUpperCase()}
</Text> </Text>
)} )}
</HStack> </HStack>
@@ -169,9 +223,11 @@ export function SearchBar(props) {
{stock.exchange && ( {stock.exchange && (
<Tag <Tag
size="sm" size="sm"
colorScheme="blue" bg="rgba(212, 175, 55, 0.15)"
variant="subtle" color={accentColor}
fontSize="xs" fontSize="xs"
fontWeight="medium"
borderRadius="md"
> >
{stock.exchange} {stock.exchange}
</Tag> </Tag>
@@ -181,10 +237,13 @@ export function SearchBar(props) {
))} ))}
</List> </List>
) : ( ) : (
<Center p={4}> <Center p={6}>
<Text color={subTextColor} fontSize="sm"> <VStack spacing={2}>
{searchQuery ? "未找到相关股票" : "输入股票代码或名称搜索"} <Search size={24} color="rgba(255, 255, 255, 0.2)" />
</Text> <Text color={subTextColor} fontSize="sm">
{searchQuery ? "未找到相关股票" : "输入代码或名称搜索"}
</Text>
</VStack>
</Center> </Center>
)} )}
</Box> </Box>

View File

@@ -223,53 +223,12 @@ const [currentMode, setCurrentMode] = useState('vertical');
setCurrentMode(mode); setCurrentMode(mode);
}, [mode]); }, [mode]);
// 看涨看跌投票处理 // 看涨看跌投票回调API 调用已在卡片组件内部处理,这里仅用于可选的列表刷新)
const handleVoteChange = useCallback(async ({ eventId, voteType }) => { const handleVoteChange = useCallback(({ eventId, voteType }) => {
if (!isLoggedIn) { console.log('[DynamicNewsCard] 投票完成回调', { eventId, voteType });
toast({ // 投票已在 HorizontalDynamicNewsEventCard 组件内部处理
title: '请先登录', // 这里可以选择是否刷新整个列表(暂不刷新,避免干扰用户体验)
description: '登录后才能参与投票', }, []);
status: 'warning',
duration: 2000,
isClosable: true,
});
return;
}
try {
const response = await fetch(`${getApiBase()}/api/events/${eventId}/sentiment-vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ vote_type: voteType }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '投票失败');
}
toast({
title: voteType === 'bullish' ? '已看涨' : '已看跌',
status: 'success',
duration: 1500,
isClosable: true,
});
// 刷新当前页数据以更新投票计数
handlePageChange(currentPage, true);
} catch (error) {
console.error('投票失败:', error);
toast({
title: '投票失败',
description: error.message,
status: 'error',
duration: 2000,
isClosable: true,
});
}
}, [isLoggedIn, toast, handlePageChange, currentPage]);
/** /**
* ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑) * ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑)

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/EventCard/HorizontalDynamicNewsEventCard.js // src/views/Community/components/EventCard/HorizontalDynamicNewsEventCard.js
// 横向布局的动态新闻事件卡片组件(时间在左,卡片在右) // 横向布局的动态新闻事件卡片组件(时间在左,卡片在右)
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
HStack, HStack,
Card, Card,
@@ -12,10 +12,13 @@ import {
Tooltip, Tooltip,
Badge, Badge,
useBreakpointValue, useBreakpointValue,
useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { getImportanceConfig } from '@constants/importanceLevels'; import { getImportanceConfig } from '@constants/importanceLevels';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import { useDevice } from '@hooks/useDevice'; import { useDevice } from '@hooks/useDevice';
import { useAuth } from '@contexts/AuthContext';
import { getApiBase } from '@utils/apiConfig';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
// 导入子组件 // 导入子组件
@@ -71,6 +74,93 @@ const HorizontalDynamicNewsEventCard = React.memo(({
}) => { }) => {
const importance = getImportanceConfig(event.importance); const importance = getImportanceConfig(event.importance);
const { isMobile } = useDevice(); const { isMobile } = useDevice();
const { isAuthenticated: isLoggedIn } = useAuth();
const toast = useToast();
// 本地状态管理投票数据(实时更新 UI
const [localBullish, setLocalBullish] = useState(event.bullish_count || 0);
const [localBearish, setLocalBearish] = useState(event.bearish_count || 0);
const [isVoting, setIsVoting] = useState(false);
// 同步 props 变化到本地状态
useEffect(() => {
setLocalBullish(event.bullish_count || 0);
}, [event.bullish_count]);
useEffect(() => {
setLocalBearish(event.bearish_count || 0);
}, [event.bearish_count]);
// 投票处理函数(直接调用 API 并更新本地状态)
const handleVote = useCallback(async (voteType) => {
if (!isLoggedIn) {
toast({
title: '请先登录',
description: '登录后才能参与投票',
status: 'warning',
duration: 2000,
isClosable: true,
});
return;
}
if (isVoting) return;
setIsVoting(true);
// 乐观更新
const oldBullish = localBullish;
const oldBearish = localBearish;
if (voteType === 'bullish') {
setLocalBullish(prev => prev + 1);
} else {
setLocalBearish(prev => prev + 1);
}
try {
const response = await fetch(`${getApiBase()}/api/events/${event.id}/sentiment-vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ vote_type: voteType }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '投票失败');
}
// 用服务端返回的真实数据更新
if (data.data) {
setLocalBullish(data.data.bullish_count ?? localBullish);
setLocalBearish(data.data.bearish_count ?? localBearish);
}
toast({
title: voteType === 'bullish' ? '已看涨' : '已看跌',
status: 'success',
duration: 1500,
isClosable: true,
});
// 通知父组件(可选,用于刷新列表)
onVoteChange?.({ eventId: event.id, voteType });
} catch (error) {
// 回滚
setLocalBullish(oldBullish);
setLocalBearish(oldBearish);
toast({
title: '投票失败',
description: error.message,
status: 'error',
duration: 2000,
isClosable: true,
});
} finally {
setIsVoting(false);
}
}, [event.id, isLoggedIn, isVoting, localBullish, localBearish, toast, onVoteChange]);
// 专业配色 - 黑色、灰色、金色主题 // 专业配色 - 黑色、灰色、金色主题
const cardBg = PROFESSIONAL_COLORS.background.card; const cardBg = PROFESSIONAL_COLORS.background.card;
@@ -275,11 +365,13 @@ const HorizontalDynamicNewsEventCard = React.memo(({
spacing={0} spacing={0}
_hover={{ bg: '#9B2C2C' }} _hover={{ bg: '#9B2C2C' }}
transition="all 0.2s" transition="all 0.2s"
onClick={() => onVoteChange?.({ eventId: event.id, voteType: 'bullish' })} onClick={() => handleVote('bullish')}
disabled={isVoting}
opacity={isVoting ? 0.7 : 1}
> >
<HStack spacing={1}> <HStack spacing={1}>
<TrendingUp size={16} /> <TrendingUp size={16} />
<Text fontSize="sm" fontWeight="semibold">{formatCompactNumber(event.bullish_count)}</Text> <Text fontSize="sm" fontWeight="semibold">{formatCompactNumber(localBullish)}</Text>
</HStack> </HStack>
<Text fontSize="xs">看涨</Text> <Text fontSize="xs">看涨</Text>
</VStack> </VStack>
@@ -296,11 +388,13 @@ const HorizontalDynamicNewsEventCard = React.memo(({
spacing={0} spacing={0}
_hover={{ bg: '#2F855A' }} _hover={{ bg: '#2F855A' }}
transition="all 0.2s" transition="all 0.2s"
onClick={() => onVoteChange?.({ eventId: event.id, voteType: 'bearish' })} onClick={() => handleVote('bearish')}
disabled={isVoting}
opacity={isVoting ? 0.7 : 1}
> >
<HStack spacing={1}> <HStack spacing={1}>
<TrendingDown size={16} /> <TrendingDown size={16} />
<Text fontSize="sm" fontWeight="semibold">{formatCompactNumber(event.bearish_count)}</Text> <Text fontSize="sm" fontWeight="semibold">{formatCompactNumber(localBearish)}</Text>
</HStack> </HStack>
<Text fontSize="xs">看跌</Text> <Text fontSize="xs">看跌</Text>
</VStack> </VStack>