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:
2025-12-15 16:03:15 +08:00
22 changed files with 443 additions and 131 deletions

3
app.py
View File

@@ -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
}

View File

@@ -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>
</>
);
};

View File

@@ -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>
);

View File

@@ -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>
{/* 加载更多按钮(仅当有更多评论时显示) */}

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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 && (

View File

@@ -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 '用户';
};

View File

@@ -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>

View File

@@ -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',
}}

View File

@@ -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>

View File

@@ -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
});
})
];

View File

@@ -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 }
);
}
}),
];

View File

@@ -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',

View File

@@ -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"

View File

@@ -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>
{/* 流动式概念展示 */}

View File

@@ -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;

View File

@@ -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>
{' '}

View File

@@ -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">

View File

@@ -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}>

View File

@@ -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
}
}
}]
};
}

View File

@@ -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({