Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui
This commit is contained in:
3
app.py
3
app.py
@@ -4763,6 +4763,8 @@ def login_with_wechat():
|
||||
'username': user.username,
|
||||
'nickname': user.nickname or user.username,
|
||||
'email': user.email,
|
||||
'phone': user.phone,
|
||||
'phone_confirmed': bool(user.phone_confirmed),
|
||||
'avatar_url': user.avatar_url,
|
||||
'has_wechat': True,
|
||||
'wechat_open_id': user.wechat_open_id,
|
||||
@@ -10958,6 +10960,7 @@ def create_event_post(event_id):
|
||||
'created_at': post.created_at.isoformat(),
|
||||
'user': {
|
||||
'id': current_user.id,
|
||||
'nickname': current_user.nickname, # 添加昵称,与导航区保持一致
|
||||
'username': current_user.username,
|
||||
'avatar_url': current_user.avatar_url
|
||||
}
|
||||
|
||||
@@ -1,29 +1,63 @@
|
||||
// src/components/EventCommentSection/CommentItem.js
|
||||
/**
|
||||
* 单条评论组件
|
||||
* 功能:显示用户头像、昵称、时间、评论内容
|
||||
* 功能:显示用户头像、昵称、时间、评论内容、删除按钮
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Avatar,
|
||||
Text,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { DeleteIcon } from '@chakra-ui/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const CommentItem = ({ comment, currentUserId, onDelete }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const cancelRef = React.useRef();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const usernameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const timeColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const contentColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
// 判断当前用户是否可以删除此评论
|
||||
const canDelete = currentUserId && (
|
||||
comment.author?.id === currentUserId ||
|
||||
comment.user_id === currentUserId
|
||||
);
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = async () => {
|
||||
if (!onDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(comment.id);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// 错误由父组件处理
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = dayjs();
|
||||
@@ -46,43 +80,99 @@ const CommentItem = ({ comment }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
bg={itemBg}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'sm',
|
||||
}}
|
||||
>
|
||||
<HStack align="start" spacing={3}>
|
||||
{/* 用户头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={comment.author?.username || 'Anonymous'}
|
||||
src={comment.author?.avatar}
|
||||
/>
|
||||
<>
|
||||
<Box
|
||||
p={3}
|
||||
bg={itemBg}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'sm',
|
||||
}}
|
||||
position="relative"
|
||||
>
|
||||
<HStack align="start" spacing={3}>
|
||||
{/* 用户头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={comment.author?.username || 'Anonymous'}
|
||||
src={comment.author?.avatar}
|
||||
/>
|
||||
|
||||
{/* 评论内容区 */}
|
||||
<VStack align="stretch" flex={1} spacing={1}>
|
||||
{/* 用户名和时间 */}
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" color={usernameColor}>
|
||||
{comment.author?.username || 'Anonymous'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={timeColor}>
|
||||
{formatTime(comment.created_at)}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 评论内容区 */}
|
||||
<VStack align="stretch" flex={1} spacing={1}>
|
||||
{/* 用户名和时间 */}
|
||||
<HStack spacing={2} justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" color={usernameColor}>
|
||||
{comment.author?.username || 'Anonymous'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={timeColor}>
|
||||
{formatTime(comment.created_at)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<Text fontSize="sm" color={contentColor} lineHeight="1.6">
|
||||
{comment.content}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
{/* 删除按钮 - 只对自己的评论显示 */}
|
||||
{canDelete && (
|
||||
<Tooltip label="删除评论" placement="top">
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
aria-label="删除评论"
|
||||
onClick={onOpen}
|
||||
opacity={0.6}
|
||||
_hover={{ opacity: 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<Text fontSize="sm" color={contentColor} lineHeight="1.6">
|
||||
{comment.content}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
删除评论
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
确定要删除这条评论吗?此操作不可撤销。
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose} isDisabled={isDeleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={handleDelete}
|
||||
ml={3}
|
||||
isLoading={isDeleting}
|
||||
loadingText="删除中..."
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { ChatIcon } from '@chakra-ui/icons';
|
||||
import CommentItem from './CommentItem';
|
||||
|
||||
const CommentList = ({ comments, loading }) => {
|
||||
const CommentList = ({ comments, loading, currentUserId, onDelete }) => {
|
||||
const emptyTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const emptyBgColor = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
@@ -58,7 +58,12 @@ const CommentList = ({ comments, loading }) => {
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
currentUserId={currentUserId}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -144,8 +144,9 @@ const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) =>
|
||||
content_type: 'text',
|
||||
author: {
|
||||
id: user?.id || 'current_user',
|
||||
username: user?.username || '当前用户',
|
||||
avatar: user?.avatar || null,
|
||||
// 与导航区保持一致:优先显示昵称
|
||||
username: user?.nickname || user?.username || user?.email || '当前用户',
|
||||
avatar: user?.avatar_url || null,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
likes_count: 0,
|
||||
@@ -187,6 +188,51 @@ const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) =>
|
||||
}
|
||||
}, [eventId, commentText, toast, user, setComments, setTotalCount]);
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*/
|
||||
const handleDeleteComment = useCallback(async (commentId: string | number) => {
|
||||
try {
|
||||
const result = await eventService.deletePost(commentId);
|
||||
|
||||
if (result.success) {
|
||||
// 从本地 state 中移除该评论
|
||||
setComments((prevComments) =>
|
||||
prevComments.filter((comment) => comment.id !== commentId)
|
||||
);
|
||||
// 总评论数 -1
|
||||
setTotalCount((prevTotal) => Math.max(0, prevTotal - 1));
|
||||
|
||||
toast({
|
||||
title: '评论已删除',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
logger.info('EventCommentSection', '评论删除成功', {
|
||||
eventId,
|
||||
commentId,
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.message || '删除评论失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('EventCommentSection', 'handleDeleteComment', error, {
|
||||
eventId,
|
||||
commentId,
|
||||
});
|
||||
toast({
|
||||
title: '删除评论失败',
|
||||
description: error.message || '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
throw error; // 重新抛出让 CommentItem 知道删除失败
|
||||
}
|
||||
}, [eventId, toast, setComments, setTotalCount]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题栏 */}
|
||||
@@ -203,7 +249,12 @@ const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) =>
|
||||
|
||||
{/* 评论列表 */}
|
||||
<Box mb={4}>
|
||||
<CommentList comments={comments} loading={loading} />
|
||||
<CommentList
|
||||
comments={comments}
|
||||
loading={loading}
|
||||
currentUserId={user?.id}
|
||||
onDelete={handleDeleteComment}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 加载更多按钮(仅当有更多评论时显示) */}
|
||||
|
||||
@@ -54,7 +54,7 @@ const MobileDrawer = memo(({
|
||||
if (user.nickname) return user.nickname;
|
||||
if (user.username) return user.username;
|
||||
if (user.email) return user.email.split('@')[0];
|
||||
if (user.phone) return user.phone;
|
||||
if (typeof user.phone === 'string' && user.phone) return user.phone;
|
||||
return '用户';
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ const MobileDrawer = memo(({
|
||||
/>
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
|
||||
{user.phone && (
|
||||
{typeof user.phone === 'string' && user.phone && (
|
||||
<Text fontSize="xs" color={emailTextColor}>{user.phone}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -40,7 +40,7 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
if (user.nickname) return user.nickname;
|
||||
if (user.username) return user.username;
|
||||
if (user.email) return user.email.split('@')[0];
|
||||
if (user.phone) return user.phone;
|
||||
if (typeof user.phone === 'string' && user.phone) return user.phone;
|
||||
return '用户';
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
{/* 用户信息区 */}
|
||||
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
|
||||
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
|
||||
{user.phone && (
|
||||
{typeof user.phone === 'string' && user.phone && (
|
||||
<Text fontSize="xs" color="gray.500">{user.phone}</Text>
|
||||
)}
|
||||
{user.has_wechat && (
|
||||
|
||||
@@ -46,7 +46,7 @@ const TabletUserMenu = memo(({
|
||||
if (user.nickname) return user.nickname;
|
||||
if (user.username) return user.username;
|
||||
if (user.email) return user.email.split('@')[0];
|
||||
if (user.phone) return user.phone;
|
||||
if (typeof user.phone === 'string' && user.phone) return user.phone;
|
||||
return '用户';
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ const TabletUserMenu = memo(({
|
||||
{/* 用户信息区 */}
|
||||
<Box px={3} py={2} borderBottom="1px" borderColor={borderColor}>
|
||||
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
|
||||
{user.phone && (
|
||||
{typeof user.phone === 'string' && user.phone && (
|
||||
<Text fontSize="xs" color="gray.500">{user.phone}</Text>
|
||||
)}
|
||||
{user.has_wechat && (
|
||||
|
||||
@@ -29,7 +29,7 @@ const UserAvatar = forwardRef(({
|
||||
if (user.nickname) return user.nickname;
|
||||
if (user.username) return user.username;
|
||||
if (user.email) return user.email.split('@')[0];
|
||||
if (user.phone) return user.phone;
|
||||
if (typeof user.phone === 'string' && user.phone) return user.phone;
|
||||
return '用户';
|
||||
};
|
||||
|
||||
|
||||
@@ -705,7 +705,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative', height: isMobile ? '450px' : '680px', width: '100%' }}>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
@@ -736,7 +736,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
<span style={{ color: '#e0e0e0' }}>加载K线数据...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
|
||||
{/* 使用 aspect-ratio 保持图表宽高比,K线图推荐 2.5:1 */}
|
||||
<div ref={chartRef} style={{ width: '100%', aspectRatio: isMobile ? '1.8 / 1' : '2.5 / 1', minHeight: isMobile ? '280px' : '400px' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,7 +251,8 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
id={`kline-chart-${stock.stock_code}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${CHART_HEIGHTS.main}px`,
|
||||
minHeight: '300px',
|
||||
height: 'min(400px, 60vh)',
|
||||
opacity: showLoading ? 0.5 : 1,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
|
||||
@@ -470,12 +470,13 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
<Modal isOpen={isOpen} onClose={onClose} size={size} isCentered>
|
||||
<ModalOverlay bg="blackAlpha.700" />
|
||||
<ModalContent
|
||||
maxW={isMobile ? '96vw' : '90vw'}
|
||||
maxH="85vh"
|
||||
w={isMobile ? '96vw' : '90vw'}
|
||||
maxW={isMobile ? '96vw' : '1400px'}
|
||||
borderRadius={isMobile ? '12px' : '8px'}
|
||||
bg="#1a1a1a"
|
||||
border="2px solid #ffd700"
|
||||
boxShadow="0 0 30px rgba(255, 215, 0, 0.5)"
|
||||
overflow="visible"
|
||||
>
|
||||
<ModalHeader pb={isMobile ? 2 : 3} borderBottomWidth="1px" borderColor="#404040">
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
@@ -498,7 +499,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box position="relative" h={isMobile ? '400px' : '600px'} w="100%">
|
||||
<Box position="relative" w="100%">
|
||||
{loading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
@@ -517,7 +518,15 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
|
||||
{/* 使用 aspect-ratio 保持图表宽高比,与日K线保持一致 */}
|
||||
<Box
|
||||
ref={chartRef}
|
||||
w="100%"
|
||||
sx={{
|
||||
aspectRatio: isMobile ? '1.8 / 1' : '2.5 / 1',
|
||||
}}
|
||||
minH={isMobile ? '280px' : '400px'}
|
||||
/>
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
|
||||
@@ -848,5 +848,18 @@ export const conceptHandlers = [
|
||||
lv1_id: lv1Id,
|
||||
lv2_id: lv2Id
|
||||
});
|
||||
}),
|
||||
|
||||
// 热门概念静态数据文件(HeroPanel 使用)
|
||||
http.get('/data/concept/latest.json', async () => {
|
||||
await delay(200);
|
||||
console.log('[Mock Concept] 获取热门概念静态数据');
|
||||
|
||||
const concepts = generatePopularConcepts(30);
|
||||
|
||||
return HttpResponse.json({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
results: concepts
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw';
|
||||
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events';
|
||||
import { getMockFutureEvents, getMockEventCountsForMonth, toggleEventFollowStatus, isEventFollowed } from '../data/account';
|
||||
import { generatePopularConcepts } from './concept';
|
||||
import { getCurrentUser } from '../data/users';
|
||||
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -1498,15 +1499,19 @@ export const eventHandlers = [
|
||||
console.log('[Mock] 发表评论, eventId:', eventId, 'content:', body.content);
|
||||
|
||||
try {
|
||||
// 获取当前登录用户信息
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 模拟创建新评论
|
||||
const newComment = {
|
||||
id: `comment_${eventId}_${Date.now()}`,
|
||||
content: body.content,
|
||||
content_type: body.content_type || 'text',
|
||||
author: {
|
||||
id: 'current_user',
|
||||
username: '当前用户',
|
||||
avatar: null,
|
||||
id: currentUser?.id || 'current_user',
|
||||
// 与导航区保持一致:优先显示昵称
|
||||
username: currentUser?.nickname || currentUser?.username || currentUser?.email || '当前用户',
|
||||
avatar: currentUser?.avatar_url || null,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
likes_count: 0,
|
||||
@@ -1536,4 +1541,45 @@ export const eventHandlers = [
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 删除帖子/评论
|
||||
http.delete('/api/posts/:postId', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { postId } = params;
|
||||
|
||||
console.log('[Mock] 删除帖子, postId:', postId);
|
||||
|
||||
try {
|
||||
// 从内存存储中删除评论
|
||||
let deleted = false;
|
||||
for (const [eventId, comments] of commentsStore.entries()) {
|
||||
const index = comments.findIndex(c => String(c.id) === String(postId));
|
||||
if (index !== -1) {
|
||||
comments.splice(index, 1);
|
||||
deleted = true;
|
||||
console.log('[Mock] 评论已从事件', eventId, '中删除');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
console.log('[Mock] 未找到评论,但仍返回成功(可能是乐观更新的评论)');
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: '删除成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 删除帖子失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '删除失败',
|
||||
message: '系统错误,请稍后重试',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -210,7 +210,8 @@ export const eventService = {
|
||||
...post,
|
||||
author: post.user ? {
|
||||
id: post.user.id,
|
||||
username: post.user.username,
|
||||
// 与导航区保持一致:优先显示昵称
|
||||
username: post.user.nickname || post.user.username,
|
||||
avatar: post.user.avatar_url || post.user.avatar // 兼容 avatar_url 和 avatar
|
||||
} : {
|
||||
id: 'anonymous',
|
||||
|
||||
@@ -60,10 +60,12 @@ const PaginationControl = React.memo(({ currentPage, totalPages, onPageChange })
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// 当前页在中间
|
||||
// 当前页在中间:显示当前页前后各1个页码
|
||||
pageNumbers.push(1);
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(currentPage);
|
||||
pageNumbers.push(currentPage - 1); // 前一页
|
||||
pageNumbers.push(currentPage); // 当前页
|
||||
pageNumbers.push(currentPage + 1); // 后一页
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(totalPages);
|
||||
}
|
||||
@@ -180,7 +182,7 @@ const PaginationControl = React.memo(({ currentPage, totalPages, onPageChange })
|
||||
{/* 输入框跳转 */}
|
||||
<HStack spacing={1.5}>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
跳转到
|
||||
第
|
||||
</Text>
|
||||
<Input
|
||||
size="xs"
|
||||
@@ -191,10 +193,13 @@ const PaginationControl = React.memo(({ currentPage, totalPages, onPageChange })
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="页"
|
||||
placeholder=""
|
||||
bg={buttonBg}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
页
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
|
||||
@@ -569,8 +569,9 @@ const FlowingConcepts = () => {
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
animation={isPaused ? 'none' : `${animationName} ${duration}s linear infinite`}
|
||||
animation={`${animationName} ${duration}s linear infinite`}
|
||||
sx={{
|
||||
animationPlayState: isPaused ? 'paused' : 'running',
|
||||
[`@keyframes ${animationName}`]: direction === 'left' ? {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-50%)' },
|
||||
@@ -952,10 +953,6 @@ const HeroPanel = () => {
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack spacing={1} px={2} py={1} bg="rgba(255,255,255,0.05)" borderRadius="full">
|
||||
<Box w="6px" h="6px" borderRadius="full" bg="gold" animation="pulse 2s infinite" />
|
||||
<Text fontSize="10px" color="whiteAlpha.600">点击查看详情</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 流动式概念展示 */}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
z-index: 10 !important;
|
||||
user-select: none !important; /* 防止连续点击时选中文本 */
|
||||
}
|
||||
|
||||
.custom-carousel-arrow:hover {
|
||||
@@ -120,13 +121,38 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 重要度徽章 - 长方形圆角 */
|
||||
.importance-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* S级 - 深红 */
|
||||
.importance-s {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* A级 - 浅红 */
|
||||
.importance-a {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
/* B级 - 深橙 */
|
||||
.importance-b {
|
||||
background: #d35400;
|
||||
}
|
||||
|
||||
/* C级 - 浅橙 */
|
||||
.importance-c {
|
||||
background: #f39c12;
|
||||
}
|
||||
|
||||
/* Card content */
|
||||
@@ -143,26 +169,18 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 标题文字 - inline显示,可以换行 */
|
||||
/* 标题文字 */
|
||||
.event-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 标签紧跟标题后面 */
|
||||
.event-tag {
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/* 涨幅标签 - 底部显示 */
|
||||
.event-tag .ant-tag {
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
transform: scale(0.9);
|
||||
vertical-align: middle;
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 详情描述 - 三行省略 */
|
||||
@@ -183,18 +201,12 @@
|
||||
.event-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.creator {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
/* 时间样式 - 年月日高亮 */
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -90,6 +90,13 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
prevArrow: <CustomArrow direction="left" />,
|
||||
nextArrow: <CustomArrow direction="right" />,
|
||||
autoplay: false,
|
||||
// 触控板/触摸优化
|
||||
swipeToSlide: true, // 允许滑动到任意位置
|
||||
touchThreshold: 10, // 滑动灵敏度
|
||||
swipe: true, // 启用滑动
|
||||
draggable: true, // PC 端拖拽支持
|
||||
useCSS: true, // CSS 动画更流畅
|
||||
cssEase: 'ease-out', // 滑动缓动效果
|
||||
beforeChange: (_current, next) => {
|
||||
// 计算实际页码(考虑无限循环)
|
||||
const actualPage = next % totalPages;
|
||||
@@ -145,11 +152,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
}}
|
||||
/>
|
||||
{event.importance && (
|
||||
<Badge
|
||||
className="importance-badge"
|
||||
color={getImportanceColor(event.importance)}
|
||||
text={`${event.importance}级`}
|
||||
/>
|
||||
<span className={`importance-badge importance-${event.importance.toLowerCase()}`}>
|
||||
{event.importance}级
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
@@ -167,9 +172,6 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="event-tag">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
@@ -185,7 +187,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
)}
|
||||
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="event-tag">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
</span>
|
||||
<span className="time">
|
||||
<span className="time-date">{dayjs(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
|
||||
@@ -400,13 +400,13 @@ export default function CenterDashboard() {
|
||||
{followingEvents.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
<IconButton
|
||||
icon={<FiPlus />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/community')}
|
||||
>
|
||||
查看更多
|
||||
</Button>
|
||||
aria-label="添加关注事件"
|
||||
/>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0} flex="1" overflowY="auto">
|
||||
|
||||
@@ -122,13 +122,20 @@ const HistoricalEvents = ({
|
||||
// navigate(`/event-detail/${event.id}`);
|
||||
// };
|
||||
|
||||
// 获取重要性颜色
|
||||
// 获取重要性颜色(用于 Badge)
|
||||
const getImportanceColor = (importance) => {
|
||||
if (importance >= 4) return 'red';
|
||||
if (importance >= 2) return 'orange';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
// 获取重要性背景色(用于卡片背景)
|
||||
const getImportanceBgColor = (importance) => {
|
||||
if (importance >= 4) return 'rgba(239, 68, 68, 0.15)'; // 红色背景
|
||||
if (importance >= 2) return 'rgba(245, 158, 11, 0.12)'; // 橙色背景
|
||||
return 'rgba(34, 197, 94, 0.1)'; // 绿色背景
|
||||
};
|
||||
|
||||
// 获取相关度颜色(1-10)
|
||||
const getSimilarityColor = (similarity) => {
|
||||
if (similarity >= 8) return 'green';
|
||||
@@ -240,27 +247,15 @@ const HistoricalEvents = ({
|
||||
<VStack spacing={3} align="stretch">
|
||||
{events.map((event) => {
|
||||
const importanceColor = getImportanceColor(event.importance);
|
||||
const importanceBgColor = getImportanceBgColor(event.importance);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={event.id}
|
||||
bg={cardBg}
|
||||
bg={importanceBgColor}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.500"
|
||||
borderRadius="lg"
|
||||
position="relative"
|
||||
overflow="visible"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
|
||||
borderTopLeftRadius: 'lg',
|
||||
borderTopRightRadius: 'lg',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={3} p={4}>
|
||||
|
||||
@@ -339,20 +339,39 @@ function getGraphOption(data) {
|
||||
};
|
||||
}
|
||||
|
||||
// 桑基图配置
|
||||
// 桑基图配置
|
||||
function getSankeyOption(data) {
|
||||
if (!data || !data.nodes || !data.links) {
|
||||
return { title: { text: '暂无桑基图数据', left: 'center', top: 'center' } };
|
||||
}
|
||||
|
||||
return {
|
||||
title: { text: '事件影响力传导流向', left: 'center', top: 10 },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
return {
|
||||
title: {
|
||||
text: '事件影响力传导流向',
|
||||
left: 'center',
|
||||
top: 5,
|
||||
textStyle: {
|
||||
color: '#00d2d3',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'mousemove',
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||
borderColor: '#444',
|
||||
textStyle: {
|
||||
color: '#fff'
|
||||
},
|
||||
formatter: (params) => {
|
||||
if (params.dataType === 'node') {
|
||||
return `<b>${params.name}</b><br/>类型: ${params.data.type || 'N/A'}<br/>层级: ${params.data.level || 'N/A'}<br/>点击查看详情`;
|
||||
return `<div style="text-align: left;">` +
|
||||
`<b style="font-size: 14px; color: #fff;">${params.name}</b><br/>` +
|
||||
`<span style="color: #aaa;">类型:</span> <span style="color: #00d2d3;">${params.data.type || 'N/A'}</span><br/>` +
|
||||
`<span style="color: #aaa;">层级:</span> <span style="color: #ffd700;">${params.data.level || 'N/A'}</span><br/>` +
|
||||
`<span style="color: #4dabf7; text-decoration: underline; cursor: pointer;">🖱️ 点击查看详情</span>` +
|
||||
`</div>`;
|
||||
}
|
||||
return params.name;
|
||||
}
|
||||
@@ -360,6 +379,10 @@ function getSankeyOption(data) {
|
||||
series: [{
|
||||
type: 'sankey',
|
||||
layout: 'none',
|
||||
top: 50, // 给标题留出空间
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 150, // 右侧留空间给标签
|
||||
emphasis: { focus: 'adjacency' },
|
||||
nodeAlign: 'justify',
|
||||
layoutIterations: 0,
|
||||
@@ -371,15 +394,67 @@ function getSankeyOption(data) {
|
||||
color: node.color,
|
||||
borderColor: node.level === 0 ? '#ffd700' : 'transparent',
|
||||
borderWidth: node.level === 0 ? 3 : 0
|
||||
},
|
||||
// 节点标签样式 - 突出显示,可点击感知
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
padding: [4, 8],
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderWidth: 1,
|
||||
// 添加下划线效果表示可点击
|
||||
rich: {
|
||||
clickable: {
|
||||
textDecoration: 'underline',
|
||||
color: '#4dabf7'
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
links: data.links.map(link => ({
|
||||
source: data.nodes[link.source]?.name,
|
||||
target: data.nodes[link.target]?.name,
|
||||
value: link.value,
|
||||
lineStyle: { color: 'source', opacity: 0.6, curveness: 0.5 }
|
||||
// 降低链条透明度,让文字更突出
|
||||
lineStyle: {
|
||||
color: 'source',
|
||||
opacity: 0.25, // 从0.6降低到0.25
|
||||
curveness: 0.5
|
||||
}
|
||||
})),
|
||||
label: { color: 'rgba(0,0,0,0.7)', fontSize: 12 }
|
||||
// 全局标签样式
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
padding: [4, 8],
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 4,
|
||||
borderColor: 'rgba(77, 171, 247, 0.5)',
|
||||
borderWidth: 1,
|
||||
formatter: '{b}'
|
||||
},
|
||||
// 高亮时的样式
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
label: {
|
||||
color: '#4dabf7',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderColor: '#4dabf7',
|
||||
borderWidth: 2
|
||||
},
|
||||
lineStyle: {
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,6 +208,10 @@ export default function ProfilePage() {
|
||||
<Button
|
||||
leftIcon={<CloseIcon />}
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
color="gray.300"
|
||||
borderColor="gray.500"
|
||||
_hover={{ bg: 'gray.700', borderColor: 'gray.400' }}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
|
||||
Reference in New Issue
Block a user