Merge branch 'feature_bugfix/20260106' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/20260106
This commit is contained in:
55
src/components/Auth/AuthBackground.js
Normal file
55
src/components/Auth/AuthBackground.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/components/Auth/AuthBackground.js
|
||||
import React from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
|
||||
/**
|
||||
* 认证页面通用背景组件
|
||||
* 用于登录和注册页面的动态渐变背景
|
||||
*/
|
||||
export default function AuthBackground() {
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
zIndex={0}
|
||||
background={`linear-gradient(45deg, rgba(139, 69, 19, 0.9) 0%, rgba(160, 82, 45, 0.8) 15%, rgba(205, 133, 63, 0.7) 30%, rgba(222, 184, 135, 0.8) 45%, rgba(245, 222, 179, 0.6) 60%, rgba(255, 228, 196, 0.7) 75%, rgba(139, 69, 19, 0.8) 100%)`}
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: `conic-gradient(from 0deg at 30% 20%, rgba(255, 140, 0, 0.6) 0deg, rgba(255, 69, 0, 0.4) 60deg, rgba(139, 69, 19, 0.5) 120deg, rgba(160, 82, 45, 0.6) 180deg, rgba(205, 133, 63, 0.4) 240deg, rgba(255, 140, 0, 0.5) 300deg, rgba(255, 140, 0, 0.6) 360deg)`,
|
||||
mixBlendMode: 'multiply',
|
||||
animation: 'fluid-rotate 20s linear infinite'
|
||||
}}
|
||||
_after={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '10%',
|
||||
left: '20%',
|
||||
width: '60%',
|
||||
height: '80%',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(ellipse at center, rgba(255, 165, 0, 0.3) 0%, rgba(255, 140, 0, 0.2) 50%, transparent 70%)',
|
||||
filter: 'blur(40px)',
|
||||
animation: 'wave-pulse 8s ease-in-out infinite'
|
||||
}}
|
||||
sx={{
|
||||
'@keyframes fluid-rotate': {
|
||||
'0%': { transform: 'rotate(0deg) scale(1)' },
|
||||
'50%': { transform: 'rotate(180deg) scale(1.1)' },
|
||||
'100%': { transform: 'rotate(360deg) scale(1)' }
|
||||
},
|
||||
'@keyframes wave-pulse': {
|
||||
'0%, 100%': { opacity: 0.4, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.8, transform: 'scale(1.2)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/components/Auth/AuthFooter.js
Normal file
58
src/components/Auth/AuthFooter.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { HStack, Text, Link as ChakraLink } from "@chakra-ui/react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* 认证页面底部组件
|
||||
* 包含页面切换链接和登录方式切换链接
|
||||
*
|
||||
* 支持两种模式:
|
||||
* 1. 页面模式:使用 linkTo 进行路由跳转
|
||||
* 2. 弹窗模式:使用 onClick 进行弹窗切换
|
||||
*/
|
||||
export default function AuthFooter({
|
||||
// 左侧链接配置
|
||||
linkText, // 提示文本,如 "还没有账号," 或 "已有账号?"
|
||||
linkLabel, // 链接文本,如 "去注册" 或 "去登录"
|
||||
linkTo, // 链接路径,如 "/auth/sign-up" 或 "/auth/sign-in"(页面模式)
|
||||
onClick, // 点击回调函数(弹窗模式,优先级高于 linkTo)
|
||||
|
||||
// 右侧切换配置
|
||||
useVerificationCode, // 当前是否使用验证码登录
|
||||
onSwitchMethod // 切换登录方式的回调函数
|
||||
}) {
|
||||
return (
|
||||
<HStack justify="space-between" width="100%">
|
||||
{/* 左侧:页面切换链接(去注册/去登录) */}
|
||||
{onClick ? (
|
||||
// 弹窗模式:使用 onClick
|
||||
<HStack spacing={1} cursor="pointer" onClick={onClick}>
|
||||
<Text fontSize="sm" color="gray.600">{linkText}</Text>
|
||||
<Text fontSize="sm" color="blue.500" fontWeight="bold">{linkLabel}</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
// 页面模式:使用 Link 组件跳转
|
||||
<HStack spacing={1} as={Link} to={linkTo}>
|
||||
<Text fontSize="sm" color="gray.600">{linkText}</Text>
|
||||
<Text fontSize="sm" color="blue.500" fontWeight="bold">{linkLabel}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 右侧:登录方式切换链接(仅在提供了切换方法时显示) */}
|
||||
{onSwitchMethod && (
|
||||
<ChakraLink
|
||||
href="#"
|
||||
fontSize="sm"
|
||||
color="blue.500"
|
||||
fontWeight="bold"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSwitchMethod();
|
||||
}}
|
||||
>
|
||||
{useVerificationCode ? '密码登陆' : '验证码登陆'}
|
||||
</ChakraLink>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
94
src/components/EventDetailPanel/MiniLineChart.js
Normal file
94
src/components/EventDetailPanel/MiniLineChart.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// src/components/EventDetailPanel/MiniLineChart.js
|
||||
// Mini 折线图组件(用于股票卡片)
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* Mini 折线图组件
|
||||
* @param {Object} props
|
||||
* @param {Array<number>} props.data - 价格走势数据数组(15个数据点:前5+中5+后5)
|
||||
* @param {number} props.width - 图表宽度(默认180)
|
||||
* @param {number} props.height - 图表高度(默认60)
|
||||
*/
|
||||
const MiniLineChart = ({ data = [], width = 180, height = 60 }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算最大值和最小值,用于归一化
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1; // 防止除以0
|
||||
|
||||
// 将数据点转换为 SVG 路径坐标
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * width;
|
||||
const y = height - ((value - min) / range) * height;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
|
||||
// 构建 SVG 路径字符串
|
||||
const pathD = `M ${points.join(' L ')}`;
|
||||
|
||||
// 判断整体趋势(比较第一个和最后一个值)
|
||||
const isPositive = data[data.length - 1] >= data[0];
|
||||
const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌
|
||||
|
||||
// 创建渐变填充区域路径
|
||||
const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`;
|
||||
|
||||
return (
|
||||
<Box width={`${width}px`} height={`${height}px`}>
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={fillPathD}
|
||||
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
|
||||
/>
|
||||
|
||||
{/* 折线 */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 垂直分隔线(标记三个时间段) */}
|
||||
{/* 前一天和当天之间 */}
|
||||
<line
|
||||
x1={width / 3}
|
||||
y1={0}
|
||||
x2={width / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
|
||||
{/* 当天和后一天之间 */}
|
||||
<line
|
||||
x1={(width * 2) / 3}
|
||||
y1={0}
|
||||
x2={(width * 2) / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniLineChart;
|
||||
353
src/components/GlobalSidebar/index.js
Normal file
353
src/components/GlobalSidebar/index.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* GlobalSidebar - 全局右侧工具栏
|
||||
*
|
||||
* 可收起/展开的侧边栏,包含关注股票和事件动态
|
||||
* 收起时点击图标显示悬浮弹窗
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Badge,
|
||||
Spinner,
|
||||
Center,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverHeader,
|
||||
PopoverCloseButton,
|
||||
Text,
|
||||
HStack,
|
||||
Portal,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeft, ChevronRight, BarChart2, Star, TrendingUp } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGlobalSidebar } from '@/contexts/GlobalSidebarContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
import { Z_INDEX, LAYOUT_SIZE } from '@/layouts/config/layoutConfig';
|
||||
import WatchSidebar from '@views/Profile/components/WatchSidebar';
|
||||
import { WatchlistPanel, FollowingEventsPanel } from '@views/Profile/components/WatchSidebar/components';
|
||||
import HotSectorsRanking from '@views/Profile/components/MarketDashboard/components/atoms/HotSectorsRanking';
|
||||
|
||||
/**
|
||||
* 收起状态下的图标菜单(带悬浮弹窗)
|
||||
*/
|
||||
const CollapsedMenu = ({
|
||||
watchlist,
|
||||
realtimeQuotes,
|
||||
followingEvents,
|
||||
eventComments,
|
||||
onToggle,
|
||||
onStockClick,
|
||||
onEventClick,
|
||||
onCommentClick,
|
||||
onAddStock,
|
||||
onAddEvent,
|
||||
onUnwatch,
|
||||
onUnfollow,
|
||||
}) => {
|
||||
return (
|
||||
<VStack spacing={4} py={4} align="center">
|
||||
{/* 展开按钮 */}
|
||||
<HStack spacing={1} w="100%" justify="center" cursor="pointer" onClick={onToggle} _hover={{ bg: 'rgba(255, 255, 255, 0.05)' }} py={1} borderRadius="md">
|
||||
<Icon as={ChevronLeft} boxSize={4} color="rgba(255, 255, 255, 0.8)" />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.8)" fontWeight="medium">
|
||||
展开
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 关注股票 - 悬浮弹窗 */}
|
||||
<Popover placement="left-start" trigger="click" isLazy>
|
||||
<PopoverTrigger>
|
||||
<VStack
|
||||
spacing={1}
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Icon as={BarChart2} boxSize={5} color="rgba(59, 130, 246, 0.9)" />
|
||||
{watchlist.length > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-4px"
|
||||
right="-8px"
|
||||
colorScheme="red"
|
||||
fontSize="9px"
|
||||
minW="16px"
|
||||
h="16px"
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{watchlist.length > 99 ? '99+' : watchlist.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.85)" fontWeight="medium" whiteSpace="nowrap">
|
||||
关注股票
|
||||
</Text>
|
||||
</VStack>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="300px"
|
||||
bg="rgba(26, 32, 44, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||
_focus={{ outline: 'none' }}
|
||||
>
|
||||
<PopoverHeader
|
||||
borderBottomColor="rgba(255, 255, 255, 0.1)"
|
||||
py={2}
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={BarChart2} boxSize={4} color="rgba(59, 130, 246, 0.9)" />
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||
关注股票 ({watchlist.length})
|
||||
</Text>
|
||||
</HStack>
|
||||
</PopoverHeader>
|
||||
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||
<PopoverBody p={2}>
|
||||
<WatchlistPanel
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
onStockClick={onStockClick}
|
||||
onAddStock={onAddStock}
|
||||
onUnwatch={onUnwatch}
|
||||
hideTitle={true}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
|
||||
{/* 事件动态 - 悬浮弹窗 */}
|
||||
<Popover placement="left-start" trigger="click" isLazy>
|
||||
<PopoverTrigger>
|
||||
<VStack
|
||||
spacing={1}
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Icon as={Star} boxSize={5} color="rgba(234, 179, 8, 0.9)" />
|
||||
{followingEvents.length > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-4px"
|
||||
right="-8px"
|
||||
colorScheme="yellow"
|
||||
fontSize="9px"
|
||||
minW="16px"
|
||||
h="16px"
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{followingEvents.length > 99 ? '99+' : followingEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.85)" fontWeight="medium" whiteSpace="nowrap">
|
||||
关注事件
|
||||
</Text>
|
||||
</VStack>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="300px"
|
||||
bg="rgba(26, 32, 44, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||
_focus={{ outline: 'none' }}
|
||||
>
|
||||
<PopoverHeader
|
||||
borderBottomColor="rgba(255, 255, 255, 0.1)"
|
||||
py={2}
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Star} boxSize={4} color="rgba(234, 179, 8, 0.9)" />
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||
事件动态
|
||||
</Text>
|
||||
</HStack>
|
||||
</PopoverHeader>
|
||||
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||
<PopoverBody p={2}>
|
||||
<FollowingEventsPanel
|
||||
events={followingEvents}
|
||||
eventComments={eventComments}
|
||||
onEventClick={onEventClick}
|
||||
onCommentClick={onCommentClick}
|
||||
onAddEvent={onAddEvent}
|
||||
onUnfollow={onUnfollow}
|
||||
hideTitle={true}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
|
||||
{/* 热门板块 - 悬浮弹窗 */}
|
||||
<Popover placement="left-start" trigger="click" isLazy>
|
||||
<PopoverTrigger>
|
||||
<VStack
|
||||
spacing={1}
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
position="relative"
|
||||
>
|
||||
<Icon as={TrendingUp} boxSize={5} color="rgba(34, 197, 94, 0.9)" />
|
||||
<Text fontSize="xs" color="rgba(255, 255, 255, 0.85)" fontWeight="medium" whiteSpace="nowrap">
|
||||
热门板块
|
||||
</Text>
|
||||
</VStack>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="280px"
|
||||
bg="rgba(26, 32, 44, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||
_focus={{ outline: 'none' }}
|
||||
>
|
||||
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||
<PopoverBody p={2}>
|
||||
<HotSectorsRanking title="热门板块" />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* GlobalSidebar 主组件
|
||||
*/
|
||||
const GlobalSidebar = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
toggle,
|
||||
watchlist,
|
||||
realtimeQuotes,
|
||||
followingEvents,
|
||||
eventComments,
|
||||
loading,
|
||||
unwatchStock,
|
||||
unfollowEvent,
|
||||
} = useGlobalSidebar();
|
||||
|
||||
// 未登录时不显示
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
w={isOpen ? '300px' : '72px'}
|
||||
h="100%"
|
||||
pt={LAYOUT_SIZE.navbarHeight}
|
||||
flexShrink={0}
|
||||
transition="width 0.2s ease-in-out"
|
||||
display={{ base: 'none', md: 'block' }}
|
||||
bg="rgba(26, 32, 44, 0.98)"
|
||||
borderLeft="1px solid rgba(255, 255, 255, 0.08)"
|
||||
position="relative"
|
||||
zIndex={Z_INDEX.SIDEBAR}
|
||||
>
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<Center position="absolute" top={4} left={0} right={0} zIndex={1}>
|
||||
<Spinner size="sm" color="rgba(212, 175, 55, 0.6)" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{isOpen ? (
|
||||
/* 展开状态 */
|
||||
<Box h="100%" display="flex" flexDirection="column">
|
||||
{/* 标题栏 - 收起按钮 + 标题 */}
|
||||
<HStack
|
||||
px={3}
|
||||
py={3}
|
||||
bg="rgba(26, 32, 44, 1)"
|
||||
borderBottom="1px solid rgba(255, 255, 255, 0.1)"
|
||||
flexShrink={0}
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon as={ChevronRight} />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)', bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
onClick={toggle}
|
||||
aria-label="收起工具栏"
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="medium" color="rgba(255, 255, 255, 0.7)">
|
||||
工具栏
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* WatchSidebar 内容 */}
|
||||
<Box flex="1" overflowY="auto" pt={2} px={2}>
|
||||
<WatchSidebar
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
followingEvents={followingEvents}
|
||||
eventComments={eventComments}
|
||||
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
||||
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
||||
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||
onAddStock={() => navigate('/stocks')}
|
||||
onAddEvent={() => navigate('/community')}
|
||||
onUnwatch={unwatchStock}
|
||||
onUnfollow={unfollowEvent}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
/* 收起状态 - 点击图标显示悬浮弹窗 */
|
||||
<CollapsedMenu
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
followingEvents={followingEvents}
|
||||
eventComments={eventComments}
|
||||
onToggle={toggle}
|
||||
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
||||
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
||||
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||
onAddStock={() => navigate('/stocks')}
|
||||
onAddEvent={() => navigate('/community')}
|
||||
onUnwatch={unwatchStock}
|
||||
onUnfollow={unfollowEvent}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSidebar;
|
||||
472
src/components/Invoice/InvoiceApplyForm.tsx
Normal file
472
src/components/Invoice/InvoiceApplyForm.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* 发票申请表单组件
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
Button,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
Checkbox,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import InvoiceTypeSelector from './InvoiceTypeSelector';
|
||||
import InvoiceTitleSelector from './InvoiceTitleSelector';
|
||||
import type {
|
||||
InvoiceType,
|
||||
InvoiceTitleType,
|
||||
InvoiceTitleTemplate,
|
||||
InvoiceableOrder,
|
||||
CreateInvoiceRequest,
|
||||
} from '@/types/invoice';
|
||||
|
||||
interface InvoiceApplyFormProps {
|
||||
order: InvoiceableOrder;
|
||||
onSubmit: (data: CreateInvoiceRequest) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
invoiceType: InvoiceType;
|
||||
titleType: InvoiceTitleType;
|
||||
title: string;
|
||||
taxNumber: string;
|
||||
companyAddress: string;
|
||||
companyPhone: string;
|
||||
bankName: string;
|
||||
bankAccount: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mailingAddress: string;
|
||||
recipientName: string;
|
||||
recipientPhone: string;
|
||||
remark: string;
|
||||
saveTemplate: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
title?: string;
|
||||
taxNumber?: string;
|
||||
email?: string;
|
||||
mailingAddress?: string;
|
||||
recipientName?: string;
|
||||
recipientPhone?: string;
|
||||
}
|
||||
|
||||
const planNameMap: Record<string, string> = {
|
||||
pro: 'Pro 专业版',
|
||||
max: 'Max 旗舰版',
|
||||
};
|
||||
|
||||
const billingCycleMap: Record<string, string> = {
|
||||
monthly: '月付',
|
||||
quarterly: '季付',
|
||||
semiannual: '半年付',
|
||||
yearly: '年付',
|
||||
};
|
||||
|
||||
export default function InvoiceApplyForm({
|
||||
order,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: InvoiceApplyFormProps) {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '',
|
||||
taxNumber: '',
|
||||
companyAddress: '',
|
||||
companyPhone: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mailingAddress: '',
|
||||
recipientName: '',
|
||||
recipientPhone: '',
|
||||
remark: '',
|
||||
saveTemplate: false,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [showNewTitleForm, setShowNewTitleForm] = useState(false);
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgCard = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 更新表单字段
|
||||
const updateField = <K extends keyof FormData>(field: K, value: FormData[K]) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// 清除对应错误
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// 选择抬头模板
|
||||
const handleSelectTemplate = (template: InvoiceTitleTemplate | null) => {
|
||||
if (template) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
title: template.title,
|
||||
titleType: template.titleType,
|
||||
taxNumber: template.taxNumber || '',
|
||||
companyAddress: template.companyAddress || '',
|
||||
companyPhone: template.companyPhone || '',
|
||||
bankName: template.bankName || '',
|
||||
bankAccount: template.bankAccount || '',
|
||||
}));
|
||||
setShowNewTitleForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换抬头类型时清空相关字段
|
||||
useEffect(() => {
|
||||
if (formData.titleType === 'personal') {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
taxNumber: '',
|
||||
companyAddress: '',
|
||||
companyPhone: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
}));
|
||||
}
|
||||
}, [formData.titleType]);
|
||||
|
||||
// 表单验证
|
||||
const validate = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = '请输入发票抬头';
|
||||
}
|
||||
|
||||
if (formData.titleType === 'company' && !formData.taxNumber.trim()) {
|
||||
newErrors.taxNumber = '企业开票必须填写税号';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = '请输入接收邮箱';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = '请输入有效的邮箱地址';
|
||||
}
|
||||
|
||||
// 纸质发票需要邮寄信息
|
||||
if (formData.invoiceType === 'paper') {
|
||||
if (!formData.mailingAddress.trim()) {
|
||||
newErrors.mailingAddress = '请输入邮寄地址';
|
||||
}
|
||||
if (!formData.recipientName.trim()) {
|
||||
newErrors.recipientName = '请输入收件人姓名';
|
||||
}
|
||||
if (!formData.recipientPhone.trim()) {
|
||||
newErrors.recipientPhone = '请输入收件人电话';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const request: CreateInvoiceRequest = {
|
||||
orderId: order.id,
|
||||
invoiceType: formData.invoiceType,
|
||||
titleType: formData.titleType,
|
||||
title: formData.title,
|
||||
taxNumber: formData.taxNumber || undefined,
|
||||
companyAddress: formData.companyAddress || undefined,
|
||||
companyPhone: formData.companyPhone || undefined,
|
||||
bankName: formData.bankName || undefined,
|
||||
bankAccount: formData.bankAccount || undefined,
|
||||
email: formData.email,
|
||||
phone: formData.phone || undefined,
|
||||
mailingAddress: formData.mailingAddress || undefined,
|
||||
recipientName: formData.recipientName || undefined,
|
||||
recipientPhone: formData.recipientPhone || undefined,
|
||||
remark: formData.remark || undefined,
|
||||
};
|
||||
|
||||
await onSubmit(request);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{/* 订单信息 */}
|
||||
<Box p={4} borderRadius="lg" bg={bgCard}>
|
||||
<Text fontSize="sm" fontWeight="600" mb={3}>
|
||||
开票订单
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
订单号
|
||||
</Text>
|
||||
<Text fontSize="sm">{order.orderNo}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
套餐
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
{planNameMap[order.planName] || order.planName} ·{' '}
|
||||
{billingCycleMap[order.billingCycle] || order.billingCycle}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
开票金额
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="600" color="blue.500">
|
||||
¥{order.amount.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 发票类型 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
发票类型
|
||||
</FormLabel>
|
||||
<InvoiceTypeSelector
|
||||
value={formData.invoiceType}
|
||||
onChange={(type) => updateField('invoiceType', type)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 抬头类型 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
抬头类型
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={formData.titleType}
|
||||
onChange={(value: InvoiceTitleType) => updateField('titleType', value)}
|
||||
>
|
||||
<Stack direction="row" spacing={6}>
|
||||
<Radio value="personal">个人</Radio>
|
||||
<Radio value="company">企业</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{/* 发票抬头选择 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
发票抬头
|
||||
</FormLabel>
|
||||
<InvoiceTitleSelector
|
||||
titleType={formData.titleType}
|
||||
onSelect={handleSelectTemplate}
|
||||
onAddNew={() => setShowNewTitleForm(true)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 新抬头表单 */}
|
||||
<Collapse in={showNewTitleForm} animateOpacity>
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={4}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel fontSize="sm">发票抬头</FormLabel>
|
||||
<Input
|
||||
placeholder={
|
||||
formData.titleType === 'company'
|
||||
? '请输入公司名称'
|
||||
: '请输入个人姓名'
|
||||
}
|
||||
value={formData.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.title}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{formData.titleType === 'company' && (
|
||||
<>
|
||||
<FormControl isRequired isInvalid={!!errors.taxNumber}>
|
||||
<FormLabel fontSize="sm">税号</FormLabel>
|
||||
<Input
|
||||
placeholder="请输入纳税人识别号"
|
||||
value={formData.taxNumber}
|
||||
onChange={(e) => updateField('taxNumber', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.taxNumber}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<Alert status="info" fontSize="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
以下为选填信息,可用于开具增值税专用发票
|
||||
</Alert>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">公司地址</FormLabel>
|
||||
<Input
|
||||
placeholder="请输入公司地址(选填)"
|
||||
value={formData.companyAddress}
|
||||
onChange={(e) => updateField('companyAddress', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">公司电话</FormLabel>
|
||||
<Input
|
||||
placeholder="请输入公司电话(选填)"
|
||||
value={formData.companyPhone}
|
||||
onChange={(e) => updateField('companyPhone', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<HStack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">开户银行</FormLabel>
|
||||
<Input
|
||||
placeholder="开户银行(选填)"
|
||||
value={formData.bankName}
|
||||
onChange={(e) => updateField('bankName', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">银行账号</FormLabel>
|
||||
<Input
|
||||
placeholder="银行账号(选填)"
|
||||
value={formData.bankAccount}
|
||||
onChange={(e) => updateField('bankAccount', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
isChecked={formData.saveTemplate}
|
||||
onChange={(e) => updateField('saveTemplate', e.target.checked)}
|
||||
>
|
||||
<Text fontSize="sm">保存为常用抬头</Text>
|
||||
</Checkbox>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 接收信息 */}
|
||||
<FormControl isRequired isInvalid={!!errors.email}>
|
||||
<FormLabel fontSize="sm" fontWeight="600">
|
||||
接收邮箱
|
||||
</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="发票将发送至此邮箱"
|
||||
value={formData.email}
|
||||
onChange={(e) => updateField('email', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.email}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">联系电话(选填)</FormLabel>
|
||||
<Input
|
||||
placeholder="方便开票人员联系"
|
||||
value={formData.phone}
|
||||
onChange={(e) => updateField('phone', e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 纸质发票邮寄信息 */}
|
||||
<Collapse in={formData.invoiceType === 'paper'} animateOpacity>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Divider />
|
||||
<Text fontSize="sm" fontWeight="600">
|
||||
邮寄信息
|
||||
</Text>
|
||||
|
||||
<FormControl isRequired isInvalid={!!errors.mailingAddress}>
|
||||
<FormLabel fontSize="sm">邮寄地址</FormLabel>
|
||||
<Textarea
|
||||
placeholder="请输入详细的邮寄地址"
|
||||
value={formData.mailingAddress}
|
||||
onChange={(e) => updateField('mailingAddress', e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<FormErrorMessage>{errors.mailingAddress}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<HStack spacing={4}>
|
||||
<FormControl isRequired isInvalid={!!errors.recipientName}>
|
||||
<FormLabel fontSize="sm">收件人</FormLabel>
|
||||
<Input
|
||||
placeholder="收件人姓名"
|
||||
value={formData.recipientName}
|
||||
onChange={(e) => updateField('recipientName', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.recipientName}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.recipientPhone}>
|
||||
<FormLabel fontSize="sm">联系电话</FormLabel>
|
||||
<Input
|
||||
placeholder="收件人电话"
|
||||
value={formData.recipientPhone}
|
||||
onChange={(e) => updateField('recipientPhone', e.target.value)}
|
||||
/>
|
||||
<FormErrorMessage>{errors.recipientPhone}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
|
||||
{/* 备注 */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">备注(选填)</FormLabel>
|
||||
<Textarea
|
||||
placeholder="如有特殊要求请在此说明"
|
||||
value={formData.remark}
|
||||
onChange={(e) => updateField('remark', e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack justify="flex-end" spacing={3} pt={4}>
|
||||
<Button variant="ghost" onClick={onCancel} isDisabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleSubmit}
|
||||
isLoading={loading}
|
||||
loadingText="提交中..."
|
||||
>
|
||||
提交申请
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
222
src/components/Invoice/InvoiceApplyModal.tsx
Normal file
222
src/components/Invoice/InvoiceApplyModal.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 发票申请弹窗
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Text,
|
||||
VStack,
|
||||
Box,
|
||||
HStack,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import InvoiceApplyForm from './InvoiceApplyForm';
|
||||
import { getAvailableOrders, applyInvoice } from '@/services/invoiceService';
|
||||
import type { InvoiceableOrder, CreateInvoiceRequest } from '@/types/invoice';
|
||||
|
||||
interface InvoiceApplyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
orderId?: string; // 可选:指定订单ID
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const planNameMap: Record<string, string> = {
|
||||
pro: 'Pro 专业版',
|
||||
max: 'Max 旗舰版',
|
||||
};
|
||||
|
||||
const billingCycleMap: Record<string, string> = {
|
||||
monthly: '月付',
|
||||
quarterly: '季付',
|
||||
semiannual: '半年付',
|
||||
yearly: '年付',
|
||||
};
|
||||
|
||||
export default function InvoiceApplyModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
orderId,
|
||||
onSuccess,
|
||||
}: InvoiceApplyModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [orders, setOrders] = useState<InvoiceableOrder[]>([]);
|
||||
const [selectedOrder, setSelectedOrder] = useState<InvoiceableOrder | null>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
const bgSelected = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadOrders();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getAvailableOrders();
|
||||
if (res.code === 200 && res.data) {
|
||||
setOrders(res.data);
|
||||
// 如果指定了订单ID,直接选中
|
||||
if (orderId) {
|
||||
const order = res.data.find((o) => o.id === orderId);
|
||||
if (order) {
|
||||
setSelectedOrder(order);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载可开票订单失败:', error);
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法获取可开票订单列表',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: CreateInvoiceRequest) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await applyInvoice(data);
|
||||
if (res.code === 200) {
|
||||
toast({
|
||||
title: '申请成功',
|
||||
description: res.message || '开票申请已提交,预计1-3个工作日内处理',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
});
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
toast({
|
||||
title: '申请失败',
|
||||
description: res.message || '开票申请提交失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交开票申请失败:', error);
|
||||
toast({
|
||||
title: '申请失败',
|
||||
description: '网络错误,请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedOrder(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
size="xl"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="85vh">
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<FileText size={20} />
|
||||
<Text>申请开票</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{loading ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text color="gray.500">加载中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : orders.length === 0 ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="lg">暂无可开票订单</Text>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
仅已支付且未开票的订单可申请开票
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : selectedOrder ? (
|
||||
<InvoiceApplyForm
|
||||
order={selectedOrder}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setSelectedOrder(null)}
|
||||
loading={submitting}
|
||||
/>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
请选择要开票的订单
|
||||
</Text>
|
||||
{orders.map((order) => (
|
||||
<Box
|
||||
key={order.id}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
_hover={{ bg: bgHover, borderColor: 'blue.400' }}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontWeight="600">
|
||||
{planNameMap[order.planName] || order.planName}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{order.orderNo} ·{' '}
|
||||
{billingCycleMap[order.billingCycle] || order.billingCycle}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
支付时间: {new Date(order.paidAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack align="flex-end" spacing={1}>
|
||||
<Text fontWeight="600" color="blue.500">
|
||||
¥{order.amount.toFixed(2)}
|
||||
</Text>
|
||||
<Button size="sm" colorScheme="blue" variant="outline">
|
||||
申请开票
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
175
src/components/Invoice/InvoiceCard.tsx
Normal file
175
src/components/Invoice/InvoiceCard.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 发票卡片组件
|
||||
* 用于展示发票信息
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { Download, Eye, X, FileText } from 'lucide-react';
|
||||
import InvoiceStatusBadge from './InvoiceStatusBadge';
|
||||
import type { InvoiceInfo } from '@/types/invoice';
|
||||
|
||||
interface InvoiceCardProps {
|
||||
invoice: InvoiceInfo;
|
||||
onView?: () => void;
|
||||
onDownload?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const invoiceTypeMap: Record<string, string> = {
|
||||
electronic: '电子发票',
|
||||
paper: '纸质发票',
|
||||
};
|
||||
|
||||
const titleTypeMap: Record<string, string> = {
|
||||
personal: '个人',
|
||||
company: '企业',
|
||||
};
|
||||
|
||||
function InvoiceCard({
|
||||
invoice,
|
||||
onView,
|
||||
onDownload,
|
||||
onCancel,
|
||||
}: InvoiceCardProps) {
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const headerBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const canDownload = invoice.status === 'completed' && invoice.invoiceType === 'electronic';
|
||||
const canCancel = invoice.status === 'pending';
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
overflow="hidden"
|
||||
transition="all 0.2s"
|
||||
_hover={{ shadow: 'md' }}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<HStack justify="space-between" p={4} bg={headerBg}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FileText} boxSize={5} color="blue.500" />
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text fontWeight="600" fontSize="sm">
|
||||
{invoice.title}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{invoiceTypeMap[invoice.invoiceType]} · {titleTypeMap[invoice.titleType]}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 内容 */}
|
||||
<VStack align="stretch" spacing={3} p={4}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
订单号
|
||||
</Text>
|
||||
<Text fontSize="sm">{invoice.orderNo}</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
开票金额
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="600" color="blue.500">
|
||||
¥{invoice.amount.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{invoice.invoiceNo && (
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
发票号码
|
||||
</Text>
|
||||
<Text fontSize="sm">{invoice.invoiceNo}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
申请时间
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
{new Date(invoice.createdAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{invoice.completedAt && (
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
开具时间
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
{new Date(invoice.completedAt).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{invoice.rejectReason && (
|
||||
<Box p={2} bg="red.50" borderRadius="md">
|
||||
<Text fontSize="xs" color="red.600">
|
||||
拒绝原因: {invoice.rejectReason}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack justify="flex-end" spacing={2} p={3}>
|
||||
{onView && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Icon as={Eye} boxSize={4} />}
|
||||
onClick={onView}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
{canDownload && onDownload && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
leftIcon={<Icon as={Download} boxSize={4} />}
|
||||
onClick={onDownload}
|
||||
>
|
||||
下载发票
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && onCancel && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
leftIcon={<Icon as={X} boxSize={4} />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消申请
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(InvoiceCard);
|
||||
37
src/components/Invoice/InvoiceStatusBadge.tsx
Normal file
37
src/components/Invoice/InvoiceStatusBadge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 发票状态徽章组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Badge } from '@chakra-ui/react';
|
||||
import type { InvoiceStatus } from '@/types/invoice';
|
||||
|
||||
interface InvoiceStatusBadgeProps {
|
||||
status: InvoiceStatus;
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
InvoiceStatus,
|
||||
{ label: string; colorScheme: string }
|
||||
> = {
|
||||
pending: { label: '待处理', colorScheme: 'yellow' },
|
||||
processing: { label: '处理中', colorScheme: 'blue' },
|
||||
completed: { label: '已开具', colorScheme: 'green' },
|
||||
rejected: { label: '已拒绝', colorScheme: 'red' },
|
||||
cancelled: { label: '已取消', colorScheme: 'gray' },
|
||||
};
|
||||
|
||||
export default function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) {
|
||||
const config = statusConfig[status] || { label: '未知', colorScheme: 'gray' };
|
||||
|
||||
return (
|
||||
<Badge
|
||||
colorScheme={config.colorScheme}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
179
src/components/Invoice/InvoiceTitleSelector.tsx
Normal file
179
src/components/Invoice/InvoiceTitleSelector.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 发票抬头选择器
|
||||
* 支持选择常用抬头或新增抬头
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { Plus, Building2, User } from 'lucide-react';
|
||||
import { getTitleTemplates } from '@/services/invoiceService';
|
||||
import type { InvoiceTitleTemplate, InvoiceTitleType } from '@/types/invoice';
|
||||
|
||||
interface InvoiceTitleSelectorProps {
|
||||
titleType: InvoiceTitleType;
|
||||
onSelect: (template: InvoiceTitleTemplate | null) => void;
|
||||
onAddNew: () => void;
|
||||
}
|
||||
|
||||
export default function InvoiceTitleSelector({
|
||||
titleType,
|
||||
onSelect,
|
||||
onAddNew,
|
||||
}: InvoiceTitleSelectorProps) {
|
||||
const [templates, setTemplates] = useState<InvoiceTitleTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getTitleTemplates();
|
||||
if (res.code === 200 && res.data) {
|
||||
setTemplates(res.data);
|
||||
// 自动选中默认模板
|
||||
const defaultTemplate = res.data.find(
|
||||
(t) => t.isDefault && t.titleType === titleType
|
||||
);
|
||||
if (defaultTemplate) {
|
||||
setSelectedId(defaultTemplate.id);
|
||||
onSelect(defaultTemplate);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载发票抬头失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据抬头类型筛选模板
|
||||
const filteredTemplates = templates.filter((t) => t.titleType === titleType);
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setSelectedId(id);
|
||||
if (id === 'new') {
|
||||
onSelect(null);
|
||||
onAddNew();
|
||||
} else {
|
||||
const template = templates.find((t) => t.id === id);
|
||||
onSelect(template || null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box py={4} textAlign="center">
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm" color="gray.500" mt={2}>
|
||||
加载常用抬头...
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{filteredTemplates.length > 0 && (
|
||||
<Text fontSize="sm" color="gray.500" mb={1}>
|
||||
选择常用抬头
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<RadioGroup value={selectedId} onChange={handleSelect}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{filteredTemplates.map((template) => (
|
||||
<Box
|
||||
key={template.id}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor={selectedId === template.id ? 'blue.500' : borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => handleSelect(template.id)}
|
||||
_hover={{ bg: bgHover }}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={3}>
|
||||
<Radio value={template.id} />
|
||||
<Icon
|
||||
as={template.titleType === 'company' ? Building2 : User}
|
||||
boxSize={4}
|
||||
color="gray.500"
|
||||
/>
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<HStack>
|
||||
<Text fontSize="sm" fontWeight="500">
|
||||
{template.title}
|
||||
</Text>
|
||||
{template.isDefault && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
默认
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{template.taxNumber && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
税号: {template.taxNumber}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 新增抬头选项 */}
|
||||
<Box
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
border="1px dashed"
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => handleSelect('new')}
|
||||
_hover={{ bg: bgHover, borderColor: 'blue.400' }}
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Radio value="new" />
|
||||
<Icon as={Plus} boxSize={4} color="blue.500" />
|
||||
<Text fontSize="sm" color="blue.500">
|
||||
使用新的发票抬头
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</RadioGroup>
|
||||
|
||||
{filteredTemplates.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Icon as={Plus} />}
|
||||
onClick={onAddNew}
|
||||
colorScheme="blue"
|
||||
>
|
||||
添加发票抬头
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
95
src/components/Invoice/InvoiceTypeSelector.tsx
Normal file
95
src/components/Invoice/InvoiceTypeSelector.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 发票类型选择器
|
||||
* 支持选择电子发票或纸质发票
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileText, Truck } from 'lucide-react';
|
||||
import type { InvoiceType } from '@/types/invoice';
|
||||
|
||||
interface InvoiceTypeSelectorProps {
|
||||
value: InvoiceType;
|
||||
onChange: (type: InvoiceType) => void;
|
||||
}
|
||||
|
||||
interface TypeOption {
|
||||
type: InvoiceType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof FileText;
|
||||
}
|
||||
|
||||
const typeOptions: TypeOption[] = [
|
||||
{
|
||||
type: 'electronic',
|
||||
label: '电子发票',
|
||||
description: '即时开具,发送至邮箱',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
type: 'paper',
|
||||
label: '纸质发票',
|
||||
description: '需填写邮寄地址,3-5个工作日',
|
||||
icon: Truck,
|
||||
},
|
||||
];
|
||||
|
||||
export default function InvoiceTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: InvoiceTypeSelectorProps) {
|
||||
const bgSelected = useColorModeValue('blue.50', 'blue.900');
|
||||
const borderSelected = useColorModeValue('blue.500', 'blue.400');
|
||||
const bgHover = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderDefault = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
return (
|
||||
<HStack spacing={4} w="100%">
|
||||
{typeOptions.map((option) => {
|
||||
const isSelected = value === option.type;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={option.type}
|
||||
flex={1}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
border="2px solid"
|
||||
borderColor={isSelected ? borderSelected : borderDefault}
|
||||
bg={isSelected ? bgSelected : 'transparent'}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
onClick={() => onChange(option.type)}
|
||||
_hover={{
|
||||
bg: isSelected ? bgSelected : bgHover,
|
||||
borderColor: isSelected ? borderSelected : 'gray.300',
|
||||
}}
|
||||
>
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<HStack>
|
||||
<Icon
|
||||
as={option.icon}
|
||||
boxSize={5}
|
||||
color={isSelected ? 'blue.500' : 'gray.500'}
|
||||
/>
|
||||
<Text fontWeight="600" fontSize="sm">
|
||||
{option.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
10
src/components/Invoice/index.ts
Normal file
10
src/components/Invoice/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 发票组件统一导出
|
||||
*/
|
||||
|
||||
export { default as InvoiceStatusBadge } from './InvoiceStatusBadge';
|
||||
export { default as InvoiceTypeSelector } from './InvoiceTypeSelector';
|
||||
export { default as InvoiceTitleSelector } from './InvoiceTitleSelector';
|
||||
export { default as InvoiceApplyForm } from './InvoiceApplyForm';
|
||||
export { default as InvoiceApplyModal } from './InvoiceApplyModal';
|
||||
export { default as InvoiceCard } from './InvoiceCard';
|
||||
83
src/components/PostHogProvider.js
Normal file
83
src/components/PostHogProvider.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/PostHogProvider.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { initPostHog } from '../lib/posthog';
|
||||
import { usePageTracking } from '../hooks/usePageTracking';
|
||||
|
||||
/**
|
||||
* PostHog Provider Component
|
||||
* Initializes PostHog SDK and provides automatic page view tracking
|
||||
*
|
||||
* Usage:
|
||||
* <PostHogProvider>
|
||||
* <App />
|
||||
* </PostHogProvider>
|
||||
*/
|
||||
export const PostHogProvider = ({ children }) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Initialize PostHog once when component mounts
|
||||
useEffect(() => {
|
||||
// Only run in browser
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Initialize PostHog
|
||||
initPostHog();
|
||||
setIsInitialized(true);
|
||||
|
||||
// Log initialization
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHogProvider initialized');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Automatically track page views
|
||||
usePageTracking({
|
||||
enabled: isInitialized,
|
||||
getProperties: (location) => {
|
||||
// Add custom properties based on route
|
||||
const properties = {};
|
||||
|
||||
// Identify page type based on path
|
||||
if (location.pathname === '/home' || location.pathname === '/home/') {
|
||||
properties.page_type = 'landing';
|
||||
} else if (location.pathname.startsWith('/home/center')) {
|
||||
properties.page_type = 'dashboard';
|
||||
} else if (location.pathname.startsWith('/auth/')) {
|
||||
properties.page_type = 'auth';
|
||||
} else if (location.pathname.startsWith('/community')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'community';
|
||||
} else if (location.pathname.startsWith('/concepts')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'concepts';
|
||||
} else if (location.pathname.startsWith('/stocks')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'stocks';
|
||||
} else if (location.pathname.startsWith('/limit-analyse')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'limit_analyse';
|
||||
} else if (location.pathname.startsWith('/trading-simulation')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'trading_simulation';
|
||||
} else if (location.pathname.startsWith('/company')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'company';
|
||||
} else if (location.pathname.startsWith('/event-detail')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'event';
|
||||
}
|
||||
|
||||
return properties;
|
||||
},
|
||||
});
|
||||
|
||||
// Don't render children until PostHog is initialized
|
||||
// This prevents tracking events before SDK is ready
|
||||
if (!isInitialized) {
|
||||
return children; // Or return a loading spinner
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default PostHogProvider;
|
||||
43
src/components/Subscription/SubscriptionModal.js
Normal file
43
src/components/Subscription/SubscriptionModal.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// src/components/Subscription/SubscriptionModal.js
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Icon,
|
||||
HStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { Star } from 'lucide-react';
|
||||
import PropTypes from 'prop-types';
|
||||
import SubscriptionContent from './SubscriptionContent';
|
||||
import { GLASS_BLUR } from '@/constants/glassConfig';
|
||||
|
||||
export default function SubscriptionModal({ isOpen, onClose }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" isCentered scrollBehavior="inside">
|
||||
<ModalOverlay backdropFilter={GLASS_BLUR.xs} />
|
||||
<ModalContent maxH="90vh">
|
||||
<ModalHeader borderBottomWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.600')}>
|
||||
<HStack>
|
||||
<Icon as={Star} color="blue.500" boxSize={5} />
|
||||
<Text>订阅管理</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody py={6}>
|
||||
<SubscriptionContent />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
SubscriptionModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
0
src/components/contexts/SidebarContext.js
Normal file → Executable file
0
src/components/contexts/SidebarContext.js
Normal file → Executable file
325
src/hooks/useDashboardEvents.js
Normal file
325
src/hooks/useDashboardEvents.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// src/hooks/useDashboardEvents.js
|
||||
// 个人中心(Dashboard/Center)事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 个人中心事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.pageType - 页面类型 ('center' | 'profile' | 'settings')
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useDashboardEvents = ({ pageType = 'center', navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
const eventMap = {
|
||||
'center': RETENTION_EVENTS.DASHBOARD_CENTER_VIEWED,
|
||||
'profile': RETENTION_EVENTS.PROFILE_PAGE_VIEWED,
|
||||
'settings': RETENTION_EVENTS.SETTINGS_PAGE_VIEWED,
|
||||
};
|
||||
|
||||
const eventName = eventMap[pageType] || RETENTION_EVENTS.DASHBOARD_VIEWED;
|
||||
|
||||
track(eventName, {
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', `📊 Dashboard Page Viewed: ${pageType}`);
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪功能卡片点击
|
||||
* @param {string} cardName - 卡片名称 ('watchlist' | 'following_events' | 'comments' | 'subscription')
|
||||
* @param {Object} cardData - 卡片数据
|
||||
*/
|
||||
const trackFunctionCardClicked = useCallback((cardName, cardData = {}) => {
|
||||
if (!cardName) {
|
||||
logger.warn('useDashboardEvents', 'Card name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.FUNCTION_CARD_CLICKED, {
|
||||
card_name: cardName,
|
||||
data_count: cardData.count || 0,
|
||||
has_data: Boolean(cardData.count && cardData.count > 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '🎴 Function Card Clicked', {
|
||||
cardName,
|
||||
count: cardData.count,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股列表查看
|
||||
* @param {number} stockCount - 自选股数量
|
||||
* @param {boolean} hasRealtime - 是否有实时行情
|
||||
*/
|
||||
const trackWatchlistViewed = useCallback((stockCount = 0, hasRealtime = false) => {
|
||||
track('Watchlist Viewed', {
|
||||
stock_count: stockCount,
|
||||
has_realtime: hasRealtime,
|
||||
is_empty: stockCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⭐ Watchlist Viewed', {
|
||||
stockCount,
|
||||
hasRealtime,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackWatchlistStockClicked = useCallback((stock, position = 0) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'watchlist',
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '🎯 Watchlist Stock Clicked', {
|
||||
stockCode: stock.code,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股添加
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {string} source - 来源 ('search' | 'stock_detail' | 'manual')
|
||||
*/
|
||||
const trackWatchlistStockAdded = useCallback((stock, source = 'manual') => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Watchlist Stock Added', {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '➕ Watchlist Stock Added', {
|
||||
stockCode: stock.code,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股移除
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
*/
|
||||
const trackWatchlistStockRemoved = useCallback((stock) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Watchlist Stock Removed', {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '➖ Watchlist Stock Removed', {
|
||||
stockCode: stock.code,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪关注的事件列表查看
|
||||
* @param {number} eventCount - 关注的事件数量
|
||||
*/
|
||||
const trackFollowingEventsViewed = useCallback((eventCount = 0) => {
|
||||
track('Following Events Viewed', {
|
||||
event_count: eventCount,
|
||||
is_empty: eventCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '📌 Following Events Viewed', {
|
||||
eventCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪关注的事件点击
|
||||
* @param {Object} event - 事件对象
|
||||
* @param {number} event.id - 事件ID
|
||||
* @param {string} event.title - 事件标题
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackFollowingEventClicked = useCallback((event, position = 0) => {
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useDashboardEvents', 'Event object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: event.id,
|
||||
news_title: event.title || '',
|
||||
source: 'following_events',
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '📰 Following Event Clicked', {
|
||||
eventId: event.id,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪事件评论列表查看
|
||||
* @param {number} commentCount - 评论数量
|
||||
*/
|
||||
const trackCommentsViewed = useCallback((commentCount = 0) => {
|
||||
track('Event Comments Viewed', {
|
||||
comment_count: commentCount,
|
||||
is_empty: commentCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '💬 Comments Viewed', {
|
||||
commentCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪订阅信息查看
|
||||
* @param {Object} subscription - 订阅信息
|
||||
* @param {string} subscription.plan - 订阅计划 ('free' | 'pro' | 'enterprise')
|
||||
* @param {string} subscription.status - 订阅状态 ('active' | 'expired' | 'cancelled')
|
||||
*/
|
||||
const trackSubscriptionViewed = useCallback((subscription = {}) => {
|
||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||
subscription_plan: subscription.plan || 'free',
|
||||
subscription_status: subscription.status || 'unknown',
|
||||
is_paid_user: subscription.plan !== 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '💳 Subscription Viewed', {
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} currentPlan - 当前计划
|
||||
* @param {string} targetPlan - 目标计划
|
||||
* @param {string} source - 来源位置
|
||||
*/
|
||||
const trackUpgradePlanClicked = useCallback((currentPlan = 'free', targetPlan = 'pro', source = 'dashboard') => {
|
||||
track(RETENTION_EVENTS.UPGRADE_PLAN_CLICKED, {
|
||||
current_plan: currentPlan,
|
||||
target_plan: targetPlan,
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⬆️ Upgrade Plan Clicked', {
|
||||
currentPlan,
|
||||
targetPlan,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪个人资料更新
|
||||
* @param {Array<string>} updatedFields - 更新的字段列表
|
||||
*/
|
||||
const trackProfileUpdated = useCallback((updatedFields = []) => {
|
||||
track(RETENTION_EVENTS.PROFILE_UPDATED, {
|
||||
updated_fields: updatedFields,
|
||||
field_count: updatedFields.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '✏️ Profile Updated', {
|
||||
updatedFields,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪设置更改
|
||||
* @param {string} settingName - 设置名称
|
||||
* @param {any} oldValue - 旧值
|
||||
* @param {any} newValue - 新值
|
||||
*/
|
||||
const trackSettingChanged = useCallback((settingName, oldValue, newValue) => {
|
||||
if (!settingName) {
|
||||
logger.warn('useDashboardEvents', 'Setting name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SETTINGS_CHANGED, {
|
||||
setting_name: settingName,
|
||||
old_value: String(oldValue),
|
||||
new_value: String(newValue),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⚙️ Setting Changed', {
|
||||
settingName,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
// 功能卡片事件
|
||||
trackFunctionCardClicked,
|
||||
|
||||
// 自选股相关事件
|
||||
trackWatchlistViewed,
|
||||
trackWatchlistStockClicked,
|
||||
trackWatchlistStockAdded,
|
||||
trackWatchlistStockRemoved,
|
||||
|
||||
// 关注事件相关
|
||||
trackFollowingEventsViewed,
|
||||
trackFollowingEventClicked,
|
||||
|
||||
// 评论相关
|
||||
trackCommentsViewed,
|
||||
|
||||
// 订阅相关
|
||||
trackSubscriptionViewed,
|
||||
trackUpgradePlanClicked,
|
||||
|
||||
// 个人资料和设置
|
||||
trackProfileUpdated,
|
||||
trackSettingChanged,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDashboardEvents;
|
||||
242
src/hooks/useEventNotifications.js
Normal file
242
src/hooks/useEventNotifications.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// src/hooks/useEventNotifications.js
|
||||
/**
|
||||
* React Hook:用于在组件中订阅事件推送通知
|
||||
*
|
||||
* 使用示例:
|
||||
* ```jsx
|
||||
* import { useEventNotifications } from 'hooks/useEventNotifications';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const { newEvent, isConnected } = useEventNotifications({
|
||||
* eventType: 'all',
|
||||
* importance: 'all',
|
||||
* onNewEvent: (event) => {
|
||||
* console.log('收到新事件:', event);
|
||||
* // 显示通知...
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* return <div>...</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import socket from '../services/socket';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const useEventNotifications = (options = {}) => {
|
||||
const {
|
||||
eventType = 'all',
|
||||
importance = 'all',
|
||||
enabled = true,
|
||||
onNewEvent,
|
||||
} = options;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [newEvent, setNewEvent] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const unsubscribeRef = useRef(null);
|
||||
|
||||
// 使用 ref 存储 onNewEvent 回调,避免因回调函数引用改变导致重新连接
|
||||
const onNewEventRef = useRef(onNewEvent);
|
||||
|
||||
// 每次 onNewEvent 改变时更新 ref
|
||||
useEffect(() => {
|
||||
onNewEventRef.current = onNewEvent;
|
||||
}, [onNewEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[useEventNotifications DEBUG] ========== useEffect 执行 ==========');
|
||||
console.log('[useEventNotifications DEBUG] enabled:', enabled);
|
||||
console.log('[useEventNotifications DEBUG] eventType:', eventType);
|
||||
console.log('[useEventNotifications DEBUG] importance:', importance);
|
||||
|
||||
// 如果禁用,则不订阅
|
||||
if (!enabled) {
|
||||
console.log('[useEventNotifications DEBUG] ⚠️ 订阅已禁用,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接状态监听
|
||||
const handleConnect = () => {
|
||||
console.log('[useEventNotifications DEBUG] ✓ WebSocket 已连接');
|
||||
logger.info('useEventNotifications', 'WebSocket connected');
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
console.log('[useEventNotifications DEBUG] ⚠️ WebSocket 已断开');
|
||||
logger.warn('useEventNotifications', 'WebSocket disconnected');
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
const handleConnectError = (err) => {
|
||||
console.error('[useEventNotifications ERROR] WebSocket 连接错误:', err);
|
||||
logger.error('useEventNotifications', 'WebSocket connect error', err);
|
||||
setError(err);
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
// 监听连接事件(必须在connect之前设置,否则可能错过事件)
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
socket.on('connect_error', handleConnectError);
|
||||
|
||||
// 连接 WebSocket
|
||||
console.log('[useEventNotifications DEBUG] 准备连接 WebSocket...');
|
||||
logger.info('useEventNotifications', 'Initializing WebSocket connection');
|
||||
|
||||
// 先检查是否已经连接
|
||||
const alreadyConnected = socket.connected || false;
|
||||
console.log('[useEventNotifications DEBUG] 当前连接状态:', alreadyConnected);
|
||||
logger.info('useEventNotifications', 'Pre-connection check', { isConnected: alreadyConnected });
|
||||
|
||||
if (alreadyConnected) {
|
||||
// 如果已经连接,直接更新状态
|
||||
console.log('[useEventNotifications DEBUG] Socket已连接,直接更新状态');
|
||||
logger.info('useEventNotifications', 'Socket already connected, updating state immediately');
|
||||
setIsConnected(true);
|
||||
// 验证状态更新
|
||||
setTimeout(() => {
|
||||
console.log('[useEventNotifications DEBUG] 1秒后验证状态更新 - isConnected应该为true');
|
||||
}, 1000);
|
||||
} else {
|
||||
// 否则建立新连接
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
// 新事件处理函数 - 使用 ref 中的回调
|
||||
const handleNewEvent = (eventData) => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== Hook 收到新事件 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 事件数据:', eventData);
|
||||
console.log('[useEventNotifications DEBUG] 事件 ID:', eventData?.id);
|
||||
console.log('[useEventNotifications DEBUG] 事件标题:', eventData?.title);
|
||||
|
||||
console.log('[useEventNotifications DEBUG] 设置 newEvent 状态');
|
||||
setNewEvent(eventData);
|
||||
console.log('[useEventNotifications DEBUG] ✓ newEvent 状态已更新');
|
||||
|
||||
// 调用外部回调(从 ref 中获取最新的回调)
|
||||
if (onNewEventRef.current) {
|
||||
console.log('[useEventNotifications DEBUG] 准备调用外部 onNewEvent 回调');
|
||||
onNewEventRef.current(eventData);
|
||||
console.log('[useEventNotifications DEBUG] ✓ 外部 onNewEvent 回调已调用');
|
||||
} else {
|
||||
console.log('[useEventNotifications DEBUG] ⚠️ 没有外部 onNewEvent 回调');
|
||||
}
|
||||
|
||||
console.log('[useEventNotifications DEBUG] ========== Hook 事件处理完成 ==========\n');
|
||||
};
|
||||
|
||||
// 订阅事件推送
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 开始订阅事件 ==========');
|
||||
console.log('[useEventNotifications DEBUG] eventType:', eventType);
|
||||
console.log('[useEventNotifications DEBUG] importance:', importance);
|
||||
console.log('[useEventNotifications DEBUG] enabled:', enabled);
|
||||
|
||||
// 检查 socket 是否有 subscribeToEvents 方法(mockSocketService 和 socketService 都有)
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: handleNewEvent,
|
||||
onSubscribed: (data) => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
|
||||
},
|
||||
});
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
|
||||
} else {
|
||||
console.warn('[useEventNotifications] socket.subscribeToEvents 方法不存在');
|
||||
}
|
||||
|
||||
// 保存取消订阅函数
|
||||
unsubscribeRef.current = () => {
|
||||
if (socket.unsubscribeFromEvents) {
|
||||
socket.unsubscribeFromEvents({ eventType });
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 清理 WebSocket 订阅 ==========');
|
||||
|
||||
// 取消订阅
|
||||
if (unsubscribeRef.current) {
|
||||
console.log('[useEventNotifications DEBUG] 取消订阅...');
|
||||
unsubscribeRef.current();
|
||||
}
|
||||
|
||||
// 移除监听器
|
||||
console.log('[useEventNotifications DEBUG] 移除事件监听器...');
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
socket.off('connect_error', handleConnectError);
|
||||
|
||||
// 注意:不断开连接,因为 socket 是全局共享的
|
||||
// 由 NotificationContext 统一管理连接生命周期
|
||||
console.log('[useEventNotifications DEBUG] ========== 清理完成 ==========\n');
|
||||
};
|
||||
}, [eventType, importance, enabled]); // 移除 onNewEvent 依赖
|
||||
|
||||
// 监控 isConnected 状态变化(调试用)
|
||||
useEffect(() => {
|
||||
console.log('[useEventNotifications DEBUG] ========== isConnected 状态变化 ==========');
|
||||
console.log('[useEventNotifications DEBUG] isConnected:', isConnected);
|
||||
console.log('[useEventNotifications DEBUG] ===========================================');
|
||||
}, [isConnected]);
|
||||
|
||||
console.log('[useEventNotifications DEBUG] Hook返回值 - isConnected:', isConnected);
|
||||
|
||||
return {
|
||||
newEvent, // 最新收到的事件
|
||||
isConnected, // WebSocket 连接状态
|
||||
error, // 错误信息
|
||||
clearNewEvent: () => setNewEvent(null), // 清除新事件状态
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 简化版 Hook:只订阅所有事件
|
||||
*/
|
||||
export const useAllEventNotifications = (onNewEvent) => {
|
||||
return useEventNotifications({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onNewEvent,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook:订阅重要事件(S 和 A 级)
|
||||
*/
|
||||
export const useImportantEventNotifications = (onNewEvent) => {
|
||||
const [importantEvents, setImportantEvents] = useState([]);
|
||||
|
||||
const handleEvent = (event) => {
|
||||
// 只处理 S 和 A 级事件
|
||||
if (event.importance === 'S' || event.importance === 'A') {
|
||||
setImportantEvents(prev => [event, ...prev].slice(0, 10)); // 最多保留 10 个
|
||||
if (onNewEvent) {
|
||||
onNewEvent(event);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = useEventNotifications({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onNewEvent: handleEvent,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
importantEvents,
|
||||
clearImportantEvents: () => setImportantEvents([]),
|
||||
};
|
||||
};
|
||||
|
||||
export default useEventNotifications;
|
||||
312
src/hooks/useFirstScreenMetrics.ts
Normal file
312
src/hooks/useFirstScreenMetrics.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 首屏性能指标收集 Hook
|
||||
* 整合 Web Vitals、资源加载、API 请求等指标
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const { metrics, isLoading, remeasure, exportMetrics } = useFirstScreenMetrics({
|
||||
* pageType: 'home',
|
||||
* enableConsoleLog: process.env.NODE_ENV === 'development'
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @module hooks/useFirstScreenMetrics
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals';
|
||||
import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor';
|
||||
import { performanceMonitor } from '@utils/performanceMonitor';
|
||||
import { usePerformanceMark } from '@hooks/usePerformanceTracker';
|
||||
import posthog from 'posthog-js';
|
||||
import type {
|
||||
FirstScreenMetrics,
|
||||
UseFirstScreenMetricsOptions,
|
||||
UseFirstScreenMetricsResult,
|
||||
FirstScreenInteractiveEventProperties,
|
||||
} from '@/types/metrics';
|
||||
|
||||
// ============================================================
|
||||
// Hook 实现
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 首屏性能指标收集 Hook
|
||||
*/
|
||||
export const useFirstScreenMetrics = (
|
||||
options: UseFirstScreenMetricsOptions
|
||||
): UseFirstScreenMetricsResult => {
|
||||
const {
|
||||
pageType,
|
||||
enableConsoleLog = process.env.NODE_ENV === 'development',
|
||||
trackToPostHog = process.env.NODE_ENV === 'production',
|
||||
customProperties = {},
|
||||
} = options;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState<FirstScreenMetrics | null>(null);
|
||||
|
||||
// 使用 ref 避免重复标记
|
||||
const hasMarkedRef = useRef(false);
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
// 在组件首次渲染时标记开始时间点
|
||||
if (!hasMarkedRef.current) {
|
||||
hasMarkedRef.current = true;
|
||||
performanceMonitor.mark(`${pageType}-page-load-start`);
|
||||
performanceMonitor.mark(`${pageType}-skeleton-start`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集所有首屏指标
|
||||
*/
|
||||
const collectAllMetrics = useCallback((): FirstScreenMetrics => {
|
||||
try {
|
||||
// 1. 初始化 Web Vitals 监控
|
||||
initWebVitalsTracking({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false, // Web Vitals 自己会上报,这里不重复
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 2. 获取 Web Vitals 指标(延迟获取,等待 LCP/FCP 等指标完成)
|
||||
const webVitalsCache = getCachedMetrics();
|
||||
const webVitals = Object.fromEntries(webVitalsCache.entries());
|
||||
|
||||
// 3. 收集资源加载统计
|
||||
const resourceStats = collectResourceStats({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false, // 避免重复上报
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 4. 收集 API 请求统计
|
||||
const apiStats = collectApiStats({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false,
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 5. 标记可交互时间点,并计算 TTI
|
||||
performanceMonitor.mark(`${pageType}-interactive`);
|
||||
const timeToInteractive = performanceMonitor.measure(
|
||||
`${pageType}-page-load-start`,
|
||||
`${pageType}-interactive`,
|
||||
`${pageType} TTI`
|
||||
) || 0;
|
||||
|
||||
// 6. 计算骨架屏展示时长
|
||||
const skeletonDisplayDuration = performanceMonitor.measure(
|
||||
`${pageType}-skeleton-start`,
|
||||
`${pageType}-interactive`,
|
||||
`${pageType} 骨架屏时长`
|
||||
) || 0;
|
||||
|
||||
const firstScreenMetrics: FirstScreenMetrics = {
|
||||
webVitals,
|
||||
resourceStats,
|
||||
apiStats,
|
||||
timeToInteractive,
|
||||
skeletonDisplayDuration,
|
||||
measuredAt: Date.now(),
|
||||
};
|
||||
|
||||
return firstScreenMetrics;
|
||||
} catch (error) {
|
||||
console.error('Failed to collect first screen metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pageType, enableConsoleLog, trackToPostHog, customProperties]);
|
||||
|
||||
/**
|
||||
* 上报首屏可交互事件到 PostHog
|
||||
*/
|
||||
const trackFirstScreenInteractive = useCallback(
|
||||
(metrics: FirstScreenMetrics) => {
|
||||
if (!trackToPostHog || process.env.NODE_ENV !== 'production') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventProperties: FirstScreenInteractiveEventProperties = {
|
||||
tti_seconds: metrics.timeToInteractive / 1000,
|
||||
skeleton_duration_seconds: metrics.skeletonDisplayDuration / 1000,
|
||||
api_request_count: metrics.apiStats.totalRequests,
|
||||
api_avg_response_time_ms: metrics.apiStats.avgResponseTime,
|
||||
page_type: pageType,
|
||||
measured_at: metrics.measuredAt,
|
||||
...customProperties,
|
||||
};
|
||||
|
||||
posthog.capture('First Screen Interactive', eventProperties);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.log('📊 Tracked First Screen Interactive to PostHog', eventProperties);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to track first screen interactive:', error);
|
||||
}
|
||||
},
|
||||
[pageType, trackToPostHog, enableConsoleLog, customProperties]
|
||||
);
|
||||
|
||||
/**
|
||||
* 手动触发重新测量
|
||||
*/
|
||||
const remeasure = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 重置性能标记
|
||||
performanceMonitor.mark(`${pageType}-page-load-start`);
|
||||
performanceMonitor.mark(`${pageType}-skeleton-start`);
|
||||
|
||||
// 延迟收集指标(等待 Web Vitals 完成)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const newMetrics = collectAllMetrics();
|
||||
setMetrics(newMetrics);
|
||||
trackFirstScreenInteractive(newMetrics);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.group('🎯 First Screen Metrics (Re-measured)');
|
||||
console.log('TTI:', `${(newMetrics.timeToInteractive / 1000).toFixed(2)}s`);
|
||||
console.log('Skeleton Duration:', `${(newMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`);
|
||||
console.log('API Requests:', newMetrics.apiStats.totalRequests);
|
||||
console.groupEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remeasure metrics:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 1000); // 延迟 1 秒收集
|
||||
}, [pageType, collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]);
|
||||
|
||||
/**
|
||||
* 导出指标为 JSON
|
||||
*/
|
||||
const exportMetrics = useCallback((): string => {
|
||||
if (!metrics) {
|
||||
return JSON.stringify({ error: 'No metrics available' }, null, 2);
|
||||
}
|
||||
|
||||
return JSON.stringify(metrics, null, 2);
|
||||
}, [metrics]);
|
||||
|
||||
/**
|
||||
* 初始化:在组件挂载时自动收集指标
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 防止重复初始化
|
||||
if (hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasInitializedRef.current = true;
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.log('🚀 useFirstScreenMetrics initialized', { pageType });
|
||||
}
|
||||
|
||||
// 延迟收集指标,等待页面渲染完成和 Web Vitals 指标就绪
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
const firstScreenMetrics = collectAllMetrics();
|
||||
setMetrics(firstScreenMetrics);
|
||||
trackFirstScreenInteractive(firstScreenMetrics);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.group('🎯 First Screen Metrics');
|
||||
console.log('━'.repeat(50));
|
||||
console.log(`✅ TTI: ${(firstScreenMetrics.timeToInteractive / 1000).toFixed(2)}s`);
|
||||
console.log(`✅ Skeleton Duration: ${(firstScreenMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`);
|
||||
console.log(`✅ API Requests: ${firstScreenMetrics.apiStats.totalRequests}`);
|
||||
console.log(`✅ API Avg Response: ${firstScreenMetrics.apiStats.avgResponseTime.toFixed(0)}ms`);
|
||||
console.log('━'.repeat(50));
|
||||
console.groupEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to collect initial metrics:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 2000); // 延迟 2 秒收集(确保 LCP/FCP 等指标已触发)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, []); // 空依赖数组,只在挂载时执行一次
|
||||
|
||||
// ============================================================
|
||||
// 返回值
|
||||
// ============================================================
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
metrics,
|
||||
remeasure,
|
||||
exportMetrics,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 辅助 Hook:标记骨架屏结束
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 标记骨架屏结束的 Hook
|
||||
* 用于在骨架屏消失时记录时间点
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const { markSkeletonEnd } = useSkeletonTiming('home-skeleton');
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (!loading) {
|
||||
* markSkeletonEnd();
|
||||
* }
|
||||
* }, [loading, markSkeletonEnd]);
|
||||
* ```
|
||||
*/
|
||||
export const useSkeletonTiming = (prefix = 'skeleton') => {
|
||||
const { mark, getMeasure } = usePerformanceMark(prefix);
|
||||
const hasMarkedEndRef = useRef(false);
|
||||
const hasMarkedStartRef = useRef(false);
|
||||
|
||||
// 在组件首次渲染时标记开始
|
||||
if (!hasMarkedStartRef.current) {
|
||||
hasMarkedStartRef.current = true;
|
||||
mark('start');
|
||||
}
|
||||
|
||||
const markSkeletonEnd = useCallback(() => {
|
||||
if (!hasMarkedEndRef.current) {
|
||||
hasMarkedEndRef.current = true;
|
||||
mark('end');
|
||||
const duration = getMeasure('start', 'end');
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && duration) {
|
||||
console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
}, [mark, getMeasure]);
|
||||
|
||||
const getSkeletonDuration = useCallback((): number | null => {
|
||||
return getMeasure('start', 'end');
|
||||
}, [getMeasure]);
|
||||
|
||||
return {
|
||||
markSkeletonEnd,
|
||||
getSkeletonDuration,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default useFirstScreenMetrics;
|
||||
101
src/hooks/usePostHog.js
Normal file
101
src/hooks/usePostHog.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/hooks/usePostHog.js
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
getPostHog,
|
||||
trackEvent,
|
||||
trackPageView,
|
||||
identifyUser,
|
||||
setUserProperties,
|
||||
resetUser,
|
||||
optIn,
|
||||
optOut,
|
||||
hasOptedOut,
|
||||
getFeatureFlag,
|
||||
isFeatureEnabled,
|
||||
} from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* Custom hook to access PostHog functionality
|
||||
* Provides convenient methods for tracking events and managing user sessions
|
||||
*
|
||||
* @returns {object} PostHog methods
|
||||
*/
|
||||
export const usePostHog = () => {
|
||||
// Get PostHog instance
|
||||
const posthog = getPostHog();
|
||||
|
||||
// Track custom event
|
||||
const track = useCallback((eventName, properties = {}) => {
|
||||
trackEvent(eventName, properties);
|
||||
}, []);
|
||||
|
||||
// Track page view
|
||||
const trackPage = useCallback((pagePath, properties = {}) => {
|
||||
trackPageView(pagePath, properties);
|
||||
}, []);
|
||||
|
||||
// Identify user
|
||||
const identify = useCallback((userId, userProperties = {}) => {
|
||||
identifyUser(userId, userProperties);
|
||||
}, []);
|
||||
|
||||
// Set user properties
|
||||
const setProperties = useCallback((properties) => {
|
||||
setUserProperties(properties);
|
||||
}, []);
|
||||
|
||||
// Reset user session (logout)
|
||||
const reset = useCallback(() => {
|
||||
resetUser();
|
||||
}, []);
|
||||
|
||||
// Opt out of tracking
|
||||
const optOutTracking = useCallback(() => {
|
||||
optOut();
|
||||
}, []);
|
||||
|
||||
// Opt in to tracking
|
||||
const optInTracking = useCallback(() => {
|
||||
optIn();
|
||||
}, []);
|
||||
|
||||
// Check if user has opted out
|
||||
const isOptedOut = useCallback(() => {
|
||||
return hasOptedOut();
|
||||
}, []);
|
||||
|
||||
// Get feature flag value
|
||||
const getFlag = useCallback((flagKey, defaultValue = false) => {
|
||||
return getFeatureFlag(flagKey, defaultValue);
|
||||
}, []);
|
||||
|
||||
// Check if feature is enabled
|
||||
const isEnabled = useCallback((flagKey) => {
|
||||
return isFeatureEnabled(flagKey);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Core PostHog instance
|
||||
posthog,
|
||||
|
||||
// Tracking methods
|
||||
track,
|
||||
trackPage,
|
||||
|
||||
// User management
|
||||
identify,
|
||||
setProperties,
|
||||
reset,
|
||||
|
||||
// Privacy controls
|
||||
optOut: optOutTracking,
|
||||
optIn: optInTracking,
|
||||
isOptedOut,
|
||||
|
||||
// Feature flags
|
||||
getFlag,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostHog;
|
||||
920
src/mocks/handlers/invoice.js
Normal file
920
src/mocks/handlers/invoice.js
Normal file
@@ -0,0 +1,920 @@
|
||||
// src/mocks/handlers/invoice.js
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { getCurrentUser } from '../data/users';
|
||||
|
||||
// 模拟网络延迟(毫秒)
|
||||
const NETWORK_DELAY = 500;
|
||||
|
||||
// 模拟发票数据存储
|
||||
const mockInvoices = new Map();
|
||||
const mockTitleTemplates = new Map();
|
||||
let invoiceIdCounter = 1000;
|
||||
let templateIdCounter = 100;
|
||||
|
||||
// 模拟可开票订单数据
|
||||
const mockInvoiceableOrders = [
|
||||
{
|
||||
id: 'ORDER_1001_1703001600000',
|
||||
orderNo: 'VF20241220001',
|
||||
planName: 'pro',
|
||||
billingCycle: 'yearly',
|
||||
amount: 2699,
|
||||
paidAt: '2024-12-20T10:00:00Z',
|
||||
invoiceApplied: false,
|
||||
},
|
||||
{
|
||||
id: 'ORDER_1002_1703088000000',
|
||||
orderNo: 'VF20241221001',
|
||||
planName: 'max',
|
||||
billingCycle: 'monthly',
|
||||
amount: 599,
|
||||
paidAt: '2024-12-21T10:00:00Z',
|
||||
invoiceApplied: false,
|
||||
},
|
||||
];
|
||||
|
||||
// 为每个用户生成模拟发票数据
|
||||
const initMockInvoices = () => {
|
||||
// 为用户 ID 1-4 都生成一些发票数据
|
||||
const userInvoiceData = [
|
||||
// 用户1 (免费用户) - 无发票
|
||||
// 用户2 (Pro会员) - 有多张发票
|
||||
{
|
||||
id: 'INV_001',
|
||||
orderId: 'ORDER_999_1702396800000',
|
||||
orderNo: 'VF20241213001',
|
||||
userId: 2,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'company',
|
||||
title: '北京价值前沿科技有限公司',
|
||||
taxNumber: '91110108MA01XXXXX',
|
||||
amount: 2699,
|
||||
email: 'pro@example.com',
|
||||
status: 'completed',
|
||||
invoiceNo: 'E20241213001',
|
||||
invoiceCode: '011001900111',
|
||||
invoiceUrl: 'https://example.com/invoices/E20241213001.pdf',
|
||||
createdAt: '2024-12-13T10:00:00Z',
|
||||
updatedAt: '2024-12-14T15:30:00Z',
|
||||
completedAt: '2024-12-14T15:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_002',
|
||||
orderId: 'ORDER_998_1701792000000',
|
||||
orderNo: 'VF20241206001',
|
||||
userId: 2,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '张三',
|
||||
amount: 599,
|
||||
email: 'pro@example.com',
|
||||
status: 'processing',
|
||||
createdAt: '2024-12-06T10:00:00Z',
|
||||
updatedAt: '2024-12-06T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_003',
|
||||
orderId: 'ORDER_997_1700000000000',
|
||||
orderNo: 'VF20241115001',
|
||||
userId: 2,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '李四',
|
||||
amount: 299,
|
||||
email: 'pro@example.com',
|
||||
status: 'pending',
|
||||
createdAt: '2024-12-24T10:00:00Z',
|
||||
updatedAt: '2024-12-24T10:00:00Z',
|
||||
},
|
||||
// 用户3 (Max会员) - 有发票
|
||||
{
|
||||
id: 'INV_004',
|
||||
orderId: 'ORDER_996_1703000000000',
|
||||
orderNo: 'VF20241220002',
|
||||
userId: 3,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'company',
|
||||
title: '上海科技发展有限公司',
|
||||
taxNumber: '91310115MA01YYYYY',
|
||||
amount: 5999,
|
||||
email: 'max@example.com',
|
||||
status: 'completed',
|
||||
invoiceNo: 'E20241220001',
|
||||
invoiceCode: '011001900222',
|
||||
invoiceUrl: 'https://example.com/invoices/E20241220001.pdf',
|
||||
createdAt: '2024-12-20T10:00:00Z',
|
||||
updatedAt: '2024-12-21T09:00:00Z',
|
||||
completedAt: '2024-12-21T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_005',
|
||||
orderId: 'ORDER_995_1702500000000',
|
||||
orderNo: 'VF20241214001',
|
||||
userId: 3,
|
||||
invoiceType: 'paper',
|
||||
titleType: 'company',
|
||||
title: '上海科技发展有限公司',
|
||||
taxNumber: '91310115MA01YYYYY',
|
||||
amount: 2699,
|
||||
email: 'max@example.com',
|
||||
status: 'processing',
|
||||
createdAt: '2024-12-14T10:00:00Z',
|
||||
updatedAt: '2024-12-15T10:00:00Z',
|
||||
},
|
||||
// 用户1 (测试用户) - 也添加一些发票方便测试
|
||||
{
|
||||
id: 'INV_006',
|
||||
orderId: 'ORDER_994_1703100000000',
|
||||
orderNo: 'VF20241222001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '测试用户',
|
||||
amount: 299,
|
||||
email: 'test@example.com',
|
||||
status: 'completed',
|
||||
invoiceNo: 'E20241222001',
|
||||
invoiceCode: '011001900333',
|
||||
invoiceUrl: 'https://example.com/invoices/E20241222001.pdf',
|
||||
createdAt: '2024-12-22T10:00:00Z',
|
||||
updatedAt: '2024-12-23T10:00:00Z',
|
||||
completedAt: '2024-12-23T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_007',
|
||||
orderId: 'ORDER_993_1703200000000',
|
||||
orderNo: 'VF20241223001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'company',
|
||||
title: '测试科技有限公司',
|
||||
taxNumber: '91110108MA01ZZZZZ',
|
||||
amount: 599,
|
||||
email: 'test@example.com',
|
||||
status: 'processing',
|
||||
createdAt: '2024-12-23T14:00:00Z',
|
||||
updatedAt: '2024-12-23T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_008',
|
||||
orderId: 'ORDER_992_1703250000000',
|
||||
orderNo: 'VF20241225001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '王五',
|
||||
amount: 199,
|
||||
email: 'test@example.com',
|
||||
status: 'pending',
|
||||
createdAt: '2024-12-25T10:00:00Z',
|
||||
updatedAt: '2024-12-25T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'INV_009',
|
||||
orderId: 'ORDER_991_1702000000000',
|
||||
orderNo: 'VF20241208001',
|
||||
userId: 1,
|
||||
invoiceType: 'electronic',
|
||||
titleType: 'personal',
|
||||
title: '赵六',
|
||||
amount: 99,
|
||||
email: 'test@example.com',
|
||||
status: 'cancelled',
|
||||
createdAt: '2024-12-08T10:00:00Z',
|
||||
updatedAt: '2024-12-09T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
userInvoiceData.forEach((invoice) => {
|
||||
mockInvoices.set(invoice.id, invoice);
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化模拟抬头模板 - 为每个用户生成
|
||||
const initMockTemplates = () => {
|
||||
const sampleTemplates = [
|
||||
// 用户1 (测试用户) 的模板
|
||||
{
|
||||
id: 'TPL_001',
|
||||
userId: 1,
|
||||
isDefault: true,
|
||||
titleType: 'company',
|
||||
title: '测试科技有限公司',
|
||||
taxNumber: '91110108MA01ZZZZZ',
|
||||
companyAddress: '北京市朝阳区建国路1号',
|
||||
companyPhone: '010-88888888',
|
||||
bankName: '中国建设银行北京分行',
|
||||
bankAccount: '1100001234567890123',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'TPL_002',
|
||||
userId: 1,
|
||||
isDefault: false,
|
||||
titleType: 'personal',
|
||||
title: '测试用户',
|
||||
createdAt: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
// 用户2 (Pro会员) 的模板
|
||||
{
|
||||
id: 'TPL_003',
|
||||
userId: 2,
|
||||
isDefault: true,
|
||||
titleType: 'company',
|
||||
title: '北京价值前沿科技有限公司',
|
||||
taxNumber: '91110108MA01XXXXX',
|
||||
companyAddress: '北京市海淀区中关村大街1号',
|
||||
companyPhone: '010-12345678',
|
||||
bankName: '中国工商银行北京分行',
|
||||
bankAccount: '0200001234567890123',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'TPL_004',
|
||||
userId: 2,
|
||||
isDefault: false,
|
||||
titleType: 'personal',
|
||||
title: '张三',
|
||||
createdAt: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
// 用户3 (Max会员) 的模板
|
||||
{
|
||||
id: 'TPL_005',
|
||||
userId: 3,
|
||||
isDefault: true,
|
||||
titleType: 'company',
|
||||
title: '上海科技发展有限公司',
|
||||
taxNumber: '91310115MA01YYYYY',
|
||||
companyAddress: '上海市浦东新区陆家嘴金融中心',
|
||||
companyPhone: '021-66666666',
|
||||
bankName: '中国银行上海分行',
|
||||
bankAccount: '4400001234567890123',
|
||||
createdAt: '2024-02-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
sampleTemplates.forEach((template) => {
|
||||
mockTitleTemplates.set(template.id, template);
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化数据
|
||||
initMockInvoices();
|
||||
initMockTemplates();
|
||||
|
||||
export const invoiceHandlers = [
|
||||
// ==================== 发票申请管理 ====================
|
||||
|
||||
// 1. 获取可开票订单列表
|
||||
http.get('/api/invoice/available-orders', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 返回未申请开票的订单
|
||||
const availableOrders = mockInvoiceableOrders.filter((order) => !order.invoiceApplied);
|
||||
|
||||
console.log('[Mock] 获取可开票订单:', { count: availableOrders.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: availableOrders,
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 申请开票
|
||||
http.post('/api/invoice/apply', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { orderId, invoiceType, titleType, title, taxNumber, email, phone, remark } = body;
|
||||
|
||||
console.log('[Mock] 申请开票:', { orderId, invoiceType, titleType, title });
|
||||
|
||||
// 验证订单
|
||||
const order = mockInvoiceableOrders.find((o) => o.id === orderId);
|
||||
if (!order) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '订单不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (order.invoiceApplied) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '该订单已申请开票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 企业开票必须有税号
|
||||
if (titleType === 'company' && !taxNumber) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '企业开票必须填写税号',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建发票申请
|
||||
const invoiceId = `INV_${invoiceIdCounter++}`;
|
||||
const invoice = {
|
||||
id: invoiceId,
|
||||
orderId: order.id,
|
||||
orderNo: order.orderNo,
|
||||
userId: currentUser.id,
|
||||
invoiceType,
|
||||
titleType,
|
||||
title,
|
||||
taxNumber: taxNumber || null,
|
||||
companyAddress: body.companyAddress || null,
|
||||
companyPhone: body.companyPhone || null,
|
||||
bankName: body.bankName || null,
|
||||
bankAccount: body.bankAccount || null,
|
||||
amount: order.amount,
|
||||
email,
|
||||
phone: phone || null,
|
||||
mailingAddress: body.mailingAddress || null,
|
||||
recipientName: body.recipientName || null,
|
||||
recipientPhone: body.recipientPhone || null,
|
||||
status: 'pending',
|
||||
remark: remark || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockInvoices.set(invoiceId, invoice);
|
||||
order.invoiceApplied = true;
|
||||
|
||||
console.log('[Mock] 发票申请创建成功:', invoice);
|
||||
|
||||
// 模拟3秒后自动变为处理中
|
||||
setTimeout(() => {
|
||||
const existingInvoice = mockInvoices.get(invoiceId);
|
||||
if (existingInvoice && existingInvoice.status === 'pending') {
|
||||
existingInvoice.status = 'processing';
|
||||
existingInvoice.updatedAt = new Date().toISOString();
|
||||
console.log(`[Mock] 发票开始处理: ${invoiceId}`);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// 模拟10秒后自动开具完成(电子发票)
|
||||
if (invoiceType === 'electronic') {
|
||||
setTimeout(() => {
|
||||
const existingInvoice = mockInvoices.get(invoiceId);
|
||||
if (existingInvoice && existingInvoice.status === 'processing') {
|
||||
existingInvoice.status = 'completed';
|
||||
existingInvoice.invoiceNo = `E${Date.now()}`;
|
||||
existingInvoice.invoiceCode = '011001900111';
|
||||
existingInvoice.invoiceUrl = `https://example.com/invoices/${existingInvoice.invoiceNo}.pdf`;
|
||||
existingInvoice.completedAt = new Date().toISOString();
|
||||
existingInvoice.updatedAt = new Date().toISOString();
|
||||
console.log(`[Mock] 电子发票开具完成: ${invoiceId}`);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '开票申请已提交,预计1-3个工作日内处理',
|
||||
data: invoice,
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 获取发票列表
|
||||
http.get('/api/invoice/list', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
|
||||
const statusFilter = url.searchParams.get('status');
|
||||
|
||||
// 获取用户的发票
|
||||
let userInvoices = Array.from(mockInvoices.values())
|
||||
.filter((invoice) => invoice.userId === currentUser.id)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
// 状态筛选
|
||||
if (statusFilter) {
|
||||
userInvoices = userInvoices.filter((invoice) => invoice.status === statusFilter);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const total = userInvoices.length;
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginatedInvoices = userInvoices.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
console.log('[Mock] 获取发票列表:', { total, page, pageSize });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: paginatedInvoices,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 获取发票详情
|
||||
http.get('/api/invoice/:invoiceId', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { invoiceId } = params;
|
||||
const invoice = mockInvoices.get(invoiceId);
|
||||
|
||||
if (!invoice) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '发票不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权访问此发票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取发票详情:', { invoiceId });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: invoice,
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 取消发票申请
|
||||
http.post('/api/invoice/:invoiceId/cancel', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { invoiceId } = params;
|
||||
const invoice = mockInvoices.get(invoiceId);
|
||||
|
||||
if (!invoice) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '发票不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权操作此发票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.status !== 'pending') {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '只能取消待处理的发票申请',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
invoice.status = 'cancelled';
|
||||
invoice.updatedAt = new Date().toISOString();
|
||||
|
||||
// 恢复订单的开票状态
|
||||
const order = mockInvoiceableOrders.find((o) => o.id === invoice.orderId);
|
||||
if (order) {
|
||||
order.invoiceApplied = false;
|
||||
}
|
||||
|
||||
console.log('[Mock] 发票申请已取消:', invoiceId);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '发票申请已取消',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 下载电子发票
|
||||
http.get('/api/invoice/:invoiceId/download', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { invoiceId } = params;
|
||||
const invoice = mockInvoices.get(invoiceId);
|
||||
|
||||
if (!invoice) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '发票不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权下载此发票',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.status !== 'completed') {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '发票尚未开具完成',
|
||||
data: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 下载电子发票:', invoiceId);
|
||||
|
||||
// 返回模拟的 PDF 内容
|
||||
const pdfContent = `%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
|
||||
endobj
|
||||
trailer
|
||||
<< /Root 1 0 R >>
|
||||
%%EOF`;
|
||||
|
||||
return new HttpResponse(pdfContent, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="invoice_${invoice.invoiceNo}.pdf"`,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 获取发票统计
|
||||
http.get('/api/invoice/stats', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userInvoices = Array.from(mockInvoices.values()).filter(
|
||||
(invoice) => invoice.userId === currentUser.id
|
||||
);
|
||||
|
||||
// 计算可开票金额(未申请开票的订单)
|
||||
const availableOrders = mockInvoiceableOrders.filter((order) => !order.invoiceApplied);
|
||||
const availableAmount = availableOrders.reduce((sum, order) => sum + order.amount, 0);
|
||||
|
||||
// 计算已开票金额
|
||||
const invoicedAmount = userInvoices
|
||||
.filter((i) => i.status === 'completed')
|
||||
.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||
|
||||
// 计算处理中金额
|
||||
const processingAmount = userInvoices
|
||||
.filter((i) => i.status === 'processing' || i.status === 'pending')
|
||||
.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||
|
||||
const stats = {
|
||||
total: userInvoices.length,
|
||||
pending: userInvoices.filter((i) => i.status === 'pending').length,
|
||||
processing: userInvoices.filter((i) => i.status === 'processing').length,
|
||||
completed: userInvoices.filter((i) => i.status === 'completed').length,
|
||||
cancelled: userInvoices.filter((i) => i.status === 'cancelled').length,
|
||||
availableAmount,
|
||||
invoicedAmount,
|
||||
processingAmount,
|
||||
};
|
||||
|
||||
console.log('[Mock] 获取发票统计:', stats);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: stats,
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 发票抬头模板管理 ====================
|
||||
|
||||
// 8. 获取发票抬头模板列表
|
||||
http.get('/api/invoice/title-templates', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userTemplates = Array.from(mockTitleTemplates.values())
|
||||
.filter((template) => template.userId === currentUser.id)
|
||||
.sort((a, b) => {
|
||||
// 默认的排在前面
|
||||
if (a.isDefault !== b.isDefault) {
|
||||
return b.isDefault ? 1 : -1;
|
||||
}
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
console.log('[Mock] 获取抬头模板列表:', { count: userTemplates.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: userTemplates,
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 保存发票抬头模板
|
||||
http.post('/api/invoice/title-template', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const templateId = `TPL_${templateIdCounter++}`;
|
||||
const template = {
|
||||
id: templateId,
|
||||
userId: currentUser.id,
|
||||
isDefault: body.isDefault || false,
|
||||
titleType: body.titleType,
|
||||
title: body.title,
|
||||
taxNumber: body.taxNumber || null,
|
||||
companyAddress: body.companyAddress || null,
|
||||
companyPhone: body.companyPhone || null,
|
||||
bankName: body.bankName || null,
|
||||
bankAccount: body.bankAccount || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 如果设为默认,取消其他模板的默认状态
|
||||
if (template.isDefault) {
|
||||
mockTitleTemplates.forEach((t) => {
|
||||
if (t.userId === currentUser.id) {
|
||||
t.isDefault = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mockTitleTemplates.set(templateId, template);
|
||||
|
||||
console.log('[Mock] 保存抬头模板:', template);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '保存成功',
|
||||
data: template,
|
||||
});
|
||||
}),
|
||||
|
||||
// 10. 删除发票抬头模板
|
||||
http.delete('/api/invoice/title-template/:templateId', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { templateId } = params;
|
||||
const template = mockTitleTemplates.get(templateId);
|
||||
|
||||
if (!template) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '模板不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (template.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权删除此模板',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
mockTitleTemplates.delete(templateId);
|
||||
|
||||
console.log('[Mock] 删除抬头模板:', templateId);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '删除成功',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 设置默认发票抬头
|
||||
http.post('/api/invoice/title-template/:templateId/default', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未登录',
|
||||
data: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { templateId } = params;
|
||||
const template = mockTitleTemplates.get(templateId);
|
||||
|
||||
if (!template) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: '模板不存在',
|
||||
data: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (template.userId !== currentUser.id) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
code: 403,
|
||||
message: '无权操作此模板',
|
||||
data: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 取消其他模板的默认状态
|
||||
mockTitleTemplates.forEach((t) => {
|
||||
if (t.userId === currentUser.id) {
|
||||
t.isDefault = false;
|
||||
}
|
||||
});
|
||||
|
||||
template.isDefault = true;
|
||||
|
||||
console.log('[Mock] 设置默认抬头:', templateId);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: '设置成功',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
];
|
||||
607
src/mocks/handlers/limitAnalyse.js
Normal file
607
src/mocks/handlers/limitAnalyse.js
Normal file
@@ -0,0 +1,607 @@
|
||||
// src/mocks/handlers/limitAnalyse.js
|
||||
// 涨停分析相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 生成可用日期列表(最近30个交易日)
|
||||
const generateAvailableDates = () => {
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < 60 && count < 30; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
// 跳过周末
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}${month}${day}`;
|
||||
|
||||
// 返回包含 date 和 count 字段的对象
|
||||
dates.push({
|
||||
date: dateStr,
|
||||
count: Math.floor(Math.random() * 80) + 30 // 30-110 只涨停股票
|
||||
});
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
// 生成板块数据
|
||||
const generateSectors = (count = 8) => {
|
||||
const sectorNames = [
|
||||
'人工智能', 'ChatGPT', '数字经济',
|
||||
'新能源汽车', '光伏', '锂电池',
|
||||
'半导体', '芯片', '5G通信',
|
||||
'医疗器械', '创新药', '中药',
|
||||
'白酒', '食品饮料', '消费电子',
|
||||
'军工', '航空航天', '新材料'
|
||||
];
|
||||
|
||||
const sectors = [];
|
||||
for (let i = 0; i < Math.min(count, sectorNames.length); i++) {
|
||||
const stockCount = Math.floor(Math.random() * 15) + 5;
|
||||
const stocks = [];
|
||||
|
||||
for (let j = 0; j < stockCount; j++) {
|
||||
stocks.push({
|
||||
code: `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`,
|
||||
name: `${sectorNames[i]}股票${j + 1}`,
|
||||
latest_limit_time: `${Math.floor(Math.random() * 4) + 9}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`,
|
||||
limit_up_count: Math.floor(Math.random() * 3) + 1,
|
||||
price: (Math.random() * 100 + 10).toFixed(2),
|
||||
change_pct: (Math.random() * 5 + 5).toFixed(2),
|
||||
turnover_rate: (Math.random() * 30 + 5).toFixed(2),
|
||||
volume: Math.floor(Math.random() * 100000000 + 10000000),
|
||||
amount: (Math.random() * 1000000000 + 100000000).toFixed(2),
|
||||
limit_type: Math.random() > 0.7 ? '一字板' : (Math.random() > 0.5 ? 'T字板' : '普通涨停'),
|
||||
封单金额: (Math.random() * 500000000).toFixed(2),
|
||||
});
|
||||
}
|
||||
|
||||
sectors.push({
|
||||
sector_name: sectorNames[i],
|
||||
stock_count: stockCount,
|
||||
avg_limit_time: `${Math.floor(Math.random() * 2) + 10}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`,
|
||||
stocks: stocks,
|
||||
});
|
||||
}
|
||||
|
||||
return sectors;
|
||||
};
|
||||
|
||||
// 生成高位股数据(用于 HighPositionStocks 组件)
|
||||
const generateHighPositionStocks = () => {
|
||||
const stocks = [];
|
||||
const stockNames = [
|
||||
'宁德时代', '比亚迪', '隆基绿能', '东方财富', '中际旭创',
|
||||
'京东方A', '海康威视', '立讯精密', '三一重工', '恒瑞医药',
|
||||
'三六零', '东方通信', '贵州茅台', '五粮液', '中国平安'
|
||||
];
|
||||
const industries = [
|
||||
'锂电池', '新能源汽车', '光伏', '金融科技', '通信设备',
|
||||
'显示器件', '安防设备', '电子元件', '工程机械', '医药制造',
|
||||
'网络安全', '通信服务', '白酒', '食品饮料', '保险'
|
||||
];
|
||||
|
||||
for (let i = 0; i < stockNames.length; i++) {
|
||||
const code = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||
const continuousDays = Math.floor(Math.random() * 8) + 2; // 2-9连板
|
||||
const price = parseFloat((Math.random() * 100 + 20).toFixed(2));
|
||||
const increaseRate = parseFloat((Math.random() * 3 + 8).toFixed(2)); // 8%-11%
|
||||
const turnoverRate = parseFloat((Math.random() * 20 + 5).toFixed(2)); // 5%-25%
|
||||
|
||||
stocks.push({
|
||||
stock_code: code,
|
||||
stock_name: stockNames[i],
|
||||
price: price,
|
||||
increase_rate: increaseRate,
|
||||
continuous_limit_up: continuousDays,
|
||||
industry: industries[i],
|
||||
turnover_rate: turnoverRate,
|
||||
});
|
||||
}
|
||||
|
||||
// 按连板天数降序排序
|
||||
stocks.sort((a, b) => b.continuous_limit_up - a.continuous_limit_up);
|
||||
|
||||
return stocks;
|
||||
};
|
||||
|
||||
// 生成高位股统计数据
|
||||
const generateHighPositionStatistics = (stocks) => {
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return {
|
||||
total_count: 0,
|
||||
avg_continuous_days: 0,
|
||||
max_continuous_days: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalCount = stocks.length;
|
||||
const sumDays = stocks.reduce((sum, stock) => sum + stock.continuous_limit_up, 0);
|
||||
const maxDays = Math.max(...stocks.map(s => s.continuous_limit_up));
|
||||
|
||||
return {
|
||||
total_count: totalCount,
|
||||
avg_continuous_days: parseFloat((sumDays / totalCount).toFixed(1)),
|
||||
max_continuous_days: maxDays,
|
||||
};
|
||||
};
|
||||
|
||||
// 生成词云数据
|
||||
const generateWordCloudData = () => {
|
||||
const keywords = [
|
||||
'人工智能', 'ChatGPT', 'AI芯片', '大模型', '算力',
|
||||
'新能源', '光伏', '锂电池', '储能', '充电桩',
|
||||
'半导体', '芯片', 'EDA', '国产替代', '集成电路',
|
||||
'医疗', '创新药', 'CXO', '医疗器械', '生物医药',
|
||||
'消费', '白酒', '食品', '零售', '餐饮',
|
||||
'金融', '券商', '保险', '银行', '金融科技'
|
||||
];
|
||||
|
||||
return keywords.map(keyword => ({
|
||||
text: keyword,
|
||||
value: Math.floor(Math.random() * 50) + 10,
|
||||
category: ['科技', '新能源', '医疗', '消费', '金融'][Math.floor(Math.random() * 5)],
|
||||
}));
|
||||
};
|
||||
|
||||
// 生成每日分析数据
|
||||
const generateDailyAnalysis = (date) => {
|
||||
const sectorNames = [
|
||||
'公告', '人工智能', 'ChatGPT', '数字经济',
|
||||
'新能源汽车', '光伏', '锂电池',
|
||||
'半导体', '芯片', '5G通信',
|
||||
'医疗器械', '创新药', '其他'
|
||||
];
|
||||
|
||||
const stockNameTemplates = [
|
||||
'龙头', '科技', '新能源', '智能', '数字', '云计算', '创新',
|
||||
'生物', '医疗', '通信', '电子', '材料', '能源', '互联'
|
||||
];
|
||||
|
||||
// 生成 sector_data(SectorDetails 组件需要的格式)
|
||||
const sectorData = {};
|
||||
let totalStocks = 0;
|
||||
|
||||
sectorNames.forEach((sectorName, sectorIdx) => {
|
||||
const stockCount = Math.floor(Math.random() * 12) + 3; // 每个板块 3-15 只股票
|
||||
const stocks = [];
|
||||
|
||||
for (let i = 0; i < stockCount; i++) {
|
||||
const code = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||
const continuousDays = Math.floor(Math.random() * 6) + 1; // 1-6连板
|
||||
const ztHour = Math.floor(Math.random() * 5) + 9; // 9-13点
|
||||
const ztMinute = Math.floor(Math.random() * 60);
|
||||
const ztSecond = Math.floor(Math.random() * 60);
|
||||
const ztTime = `2024-10-28 ${String(ztHour).padStart(2, '0')}:${String(ztMinute).padStart(2, '0')}:${String(ztSecond).padStart(2, '0')}`;
|
||||
|
||||
const stockName = `${stockNameTemplates[i % stockNameTemplates.length]}${sectorName === '公告' ? '公告' : ''}股份${i + 1}`;
|
||||
|
||||
stocks.push({
|
||||
scode: code,
|
||||
sname: stockName,
|
||||
zt_time: ztTime,
|
||||
formatted_time: `${String(ztHour).padStart(2, '0')}:${String(ztMinute).padStart(2, '0')}`,
|
||||
continuous_days: continuousDays === 1 ? '首板' : `${continuousDays}连板`,
|
||||
brief: `${sectorName}板块异动,${stockName}因${sectorName === '公告' ? '重大公告利好' : '板块热点'}涨停。公司是${sectorName}行业龙头企业之一。`,
|
||||
summary: `${sectorName}概念持续活跃`,
|
||||
first_time: `2024-10-${String(28 - (continuousDays - 1)).padStart(2, '0')}`,
|
||||
change_pct: parseFloat((Math.random() * 2 + 9).toFixed(2)), // 9%-11%
|
||||
core_sectors: [
|
||||
sectorName,
|
||||
sectorNames[Math.floor(Math.random() * sectorNames.length)],
|
||||
sectorNames[Math.floor(Math.random() * sectorNames.length)]
|
||||
].filter((v, i, a) => a.indexOf(v) === i) // 去重
|
||||
});
|
||||
}
|
||||
|
||||
sectorData[sectorName] = {
|
||||
count: stockCount,
|
||||
stocks: stocks.sort((a, b) => a.zt_time.localeCompare(b.zt_time)) // 按涨停时间排序
|
||||
};
|
||||
|
||||
totalStocks += stockCount;
|
||||
});
|
||||
|
||||
// 统计数据
|
||||
const morningCount = Math.floor(totalStocks * 0.35); // 早盘涨停
|
||||
const middayCount = Math.floor(totalStocks * 0.25); // 午盘涨停
|
||||
const afternoonCount = totalStocks - morningCount - middayCount; // 尾盘涨停
|
||||
const announcementCount = sectorData['公告']?.count || 0;
|
||||
const topSector = sectorNames.filter(s => s !== '公告' && s !== '其他')
|
||||
.reduce((max, name) =>
|
||||
(sectorData[name]?.count || 0) > (sectorData[max]?.count || 0) ? name : max
|
||||
, '人工智能');
|
||||
|
||||
// 生成 chart_data(板块分布饼图需要)
|
||||
const sortedSectors = Object.entries(sectorData)
|
||||
.filter(([name]) => name !== '其他' && name !== '公告')
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.slice(0, 10);
|
||||
|
||||
const chartData = {
|
||||
labels: sortedSectors.map(([name]) => name),
|
||||
counts: sortedSectors.map(([, info]) => info.count)
|
||||
};
|
||||
|
||||
return {
|
||||
date: date,
|
||||
total_stocks: totalStocks,
|
||||
total_sectors: Object.keys(sectorData).length,
|
||||
sector_data: sectorData, // 👈 SectorDetails 组件需要的数据
|
||||
chart_data: chartData, // 👈 板块分布饼图需要的数据
|
||||
summary: {
|
||||
top_sector: topSector,
|
||||
top_sector_count: sectorData[topSector]?.count || 0,
|
||||
announcement_stocks: announcementCount,
|
||||
zt_time_distribution: {
|
||||
morning: morningCount,
|
||||
midday: middayCount,
|
||||
afternoon: afternoonCount,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 静态文件 Mock Handlers ====================
|
||||
// 这些 handlers 用于拦截 /data/zt/* 静态文件请求
|
||||
|
||||
// 生成 dates.json 数据
|
||||
const generateDatesJson = () => {
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
// 跳过周末
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
dates.push({
|
||||
date: `${year}${month}${day}`,
|
||||
formatted_date: `${year}-${month}-${day}`,
|
||||
count: Math.floor(Math.random() * 60) + 40 // 40-100 只涨停股票
|
||||
});
|
||||
|
||||
if (dates.length >= 30) break;
|
||||
}
|
||||
}
|
||||
|
||||
return { dates };
|
||||
};
|
||||
|
||||
// 生成每日分析 JSON 数据(用于 /data/zt/daily/${date}.json)
|
||||
const generateDailyJson = (date) => {
|
||||
// 板块名称列表
|
||||
const sectorNames = [
|
||||
'公告', '人工智能', 'ChatGPT', '大模型', '算力',
|
||||
'光伏', '新能源汽车', '锂电池', '储能', '充电桩',
|
||||
'半导体', '芯片', '集成电路', '国产替代',
|
||||
'医药', '创新药', 'CXO', '医疗器械',
|
||||
'军工', '航空航天', '其他'
|
||||
];
|
||||
|
||||
// 股票名称模板
|
||||
const stockPrefixes = [
|
||||
'龙头', '科技', '新能', '智能', '数字', '云计', '创新',
|
||||
'生物', '医疗', '通信', '电子', '材料', '能源', '互联',
|
||||
'天马', '华鑫', '中科', '东方', '西部', '南方', '北方',
|
||||
'金龙', '银河', '星辰', '宏达', '盛世', '鹏程', '万里'
|
||||
];
|
||||
|
||||
const stockSuffixes = [
|
||||
'股份', '科技', '电子', '信息', '新材', '能源', '医药',
|
||||
'通讯', '智造', '集团', '实业', '控股', '产业', '发展'
|
||||
];
|
||||
|
||||
// 生成所有股票
|
||||
const stocks = [];
|
||||
const sectorData = {};
|
||||
let stockIndex = 0;
|
||||
|
||||
sectorNames.forEach((sectorName, sectorIdx) => {
|
||||
const stockCount = sectorName === '公告'
|
||||
? Math.floor(Math.random() * 5) + 8 // 公告板块 8-12 只
|
||||
: sectorName === '其他'
|
||||
? Math.floor(Math.random() * 4) + 3 // 其他板块 3-6 只
|
||||
: Math.floor(Math.random() * 8) + 3; // 普通板块 3-10 只
|
||||
|
||||
const sectorStockCodes = [];
|
||||
|
||||
for (let i = 0; i < stockCount; i++) {
|
||||
const code = `${Math.random() > 0.6 ? '6' : Math.random() > 0.3 ? '0' : '3'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||
const continuousDays = Math.floor(Math.random() * 6) + 1;
|
||||
const ztHour = Math.floor(Math.random() * 4) + 9;
|
||||
const ztMinute = Math.floor(Math.random() * 60);
|
||||
const ztSecond = Math.floor(Math.random() * 60);
|
||||
|
||||
const prefix = stockPrefixes[Math.floor(Math.random() * stockPrefixes.length)];
|
||||
const suffix = stockSuffixes[Math.floor(Math.random() * stockSuffixes.length)];
|
||||
const stockName = `${prefix}${suffix}`;
|
||||
|
||||
// 生成关联板块
|
||||
const coreSectors = [sectorName];
|
||||
if (Math.random() > 0.5) {
|
||||
const otherSector = sectorNames[Math.floor(Math.random() * (sectorNames.length - 1))];
|
||||
if (otherSector !== sectorName && otherSector !== '其他' && otherSector !== '公告') {
|
||||
coreSectors.push(otherSector);
|
||||
}
|
||||
}
|
||||
|
||||
stocks.push({
|
||||
scode: code,
|
||||
sname: stockName,
|
||||
zt_time: `${date.slice(0,4)}-${date.slice(4,6)}-${date.slice(6,8)} ${String(ztHour).padStart(2,'0')}:${String(ztMinute).padStart(2,'0')}:${String(ztSecond).padStart(2,'0')}`,
|
||||
formatted_time: `${String(ztHour).padStart(2,'0')}:${String(ztMinute).padStart(2,'0')}`,
|
||||
continuous_days: continuousDays === 1 ? '首板' : `${continuousDays}连板`,
|
||||
brief: sectorName === '公告'
|
||||
? `${stockName}发布重大公告,公司拟收购资产/重组/增发等利好消息。`
|
||||
: `${sectorName}板块异动,${stockName}因板块热点涨停。公司是${sectorName}行业核心标的。`,
|
||||
summary: `${sectorName}概念活跃`,
|
||||
first_time: `${date.slice(0,4)}-${date.slice(4,6)}-${String(parseInt(date.slice(6,8)) - (continuousDays - 1)).padStart(2,'0')}`,
|
||||
change_pct: parseFloat((Math.random() * 1.5 + 9.5).toFixed(2)),
|
||||
core_sectors: coreSectors
|
||||
});
|
||||
|
||||
sectorStockCodes.push(code);
|
||||
stockIndex++;
|
||||
}
|
||||
|
||||
sectorData[sectorName] = {
|
||||
count: stockCount,
|
||||
stock_codes: sectorStockCodes
|
||||
};
|
||||
});
|
||||
|
||||
// 生成词频数据
|
||||
const wordFreqData = [
|
||||
{ name: '人工智能', value: Math.floor(Math.random() * 30) + 20 },
|
||||
{ name: 'ChatGPT', value: Math.floor(Math.random() * 25) + 15 },
|
||||
{ name: '大模型', value: Math.floor(Math.random() * 20) + 12 },
|
||||
{ name: '算力', value: Math.floor(Math.random() * 18) + 10 },
|
||||
{ name: '光伏', value: Math.floor(Math.random() * 15) + 10 },
|
||||
{ name: '新能源', value: Math.floor(Math.random() * 15) + 8 },
|
||||
{ name: '锂电池', value: Math.floor(Math.random() * 12) + 8 },
|
||||
{ name: '储能', value: Math.floor(Math.random() * 12) + 6 },
|
||||
{ name: '半导体', value: Math.floor(Math.random() * 15) + 10 },
|
||||
{ name: '芯片', value: Math.floor(Math.random() * 15) + 8 },
|
||||
{ name: '集成电路', value: Math.floor(Math.random() * 10) + 5 },
|
||||
{ name: '国产替代', value: Math.floor(Math.random() * 10) + 5 },
|
||||
{ name: '医药', value: Math.floor(Math.random() * 12) + 6 },
|
||||
{ name: '创新药', value: Math.floor(Math.random() * 10) + 5 },
|
||||
{ name: '医疗器械', value: Math.floor(Math.random() * 8) + 4 },
|
||||
{ name: '军工', value: Math.floor(Math.random() * 10) + 5 },
|
||||
{ name: '航空航天', value: Math.floor(Math.random() * 8) + 4 },
|
||||
{ name: '数字经济', value: Math.floor(Math.random() * 12) + 6 },
|
||||
{ name: '工业4.0', value: Math.floor(Math.random() * 8) + 4 },
|
||||
{ name: '机器人', value: Math.floor(Math.random() * 10) + 5 },
|
||||
{ name: '自动驾驶', value: Math.floor(Math.random() * 8) + 4 },
|
||||
{ name: '元宇宙', value: Math.floor(Math.random() * 6) + 3 },
|
||||
{ name: 'Web3.0', value: Math.floor(Math.random() * 5) + 2 },
|
||||
{ name: '区块链', value: Math.floor(Math.random() * 5) + 2 },
|
||||
];
|
||||
|
||||
// 生成 chart_data(板块分布饼图需要)
|
||||
const sortedSectors = Object.entries(sectorData)
|
||||
.filter(([name]) => name !== '其他' && name !== '公告')
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.slice(0, 10);
|
||||
|
||||
const chartData = {
|
||||
labels: sortedSectors.map(([name]) => name),
|
||||
counts: sortedSectors.map(([, info]) => info.count)
|
||||
};
|
||||
|
||||
// 时间分布(早盘、午盘、尾盘)
|
||||
const morningCount = Math.floor(stocks.length * 0.35);
|
||||
const middayCount = Math.floor(stocks.length * 0.25);
|
||||
const afternoonCount = stocks.length - morningCount - middayCount;
|
||||
|
||||
return {
|
||||
date: date,
|
||||
total_stocks: stocks.length,
|
||||
total_sectors: Object.keys(sectorData).length,
|
||||
stocks: stocks,
|
||||
sector_data: sectorData,
|
||||
word_freq_data: wordFreqData,
|
||||
chart_data: chartData, // 👈 板块分布饼图需要的数据
|
||||
summary: {
|
||||
top_sector: '人工智能',
|
||||
top_sector_count: sectorData['人工智能']?.count || 0,
|
||||
announcement_stocks: sectorData['公告']?.count || 0,
|
||||
zt_time_distribution: {
|
||||
morning: morningCount, // 早盘 9:30-11:30
|
||||
midday: middayCount, // 午盘 11:30-13:00
|
||||
afternoon: afternoonCount, // 尾盘 13:00-15:00
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 生成 stocks.jsonl 数据
|
||||
const generateStocksJsonl = () => {
|
||||
const stocks = [];
|
||||
const today = new Date();
|
||||
|
||||
// 生成 200 只历史涨停股票记录
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const daysAgo = Math.floor(Math.random() * 30);
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
// 跳过周末
|
||||
while (date.getDay() === 0 || date.getDay() === 6) {
|
||||
date.setDate(date.getDate() - 1);
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
stocks.push({
|
||||
scode: `${Math.random() > 0.6 ? '6' : Math.random() > 0.3 ? '0' : '3'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`,
|
||||
sname: ['龙头', '科技', '新能', '智能', '数字', '云计'][Math.floor(Math.random() * 6)] +
|
||||
['股份', '科技', '电子', '信息', '新材'][Math.floor(Math.random() * 5)],
|
||||
date: `${year}${month}${day}`,
|
||||
formatted_date: `${year}-${month}-${day}`,
|
||||
continuous_days: Math.floor(Math.random() * 5) + 1,
|
||||
core_sectors: [['人工智能', 'ChatGPT', '光伏', '锂电池', '芯片'][Math.floor(Math.random() * 5)]]
|
||||
});
|
||||
}
|
||||
|
||||
return stocks;
|
||||
};
|
||||
|
||||
// Mock Handlers
|
||||
export const limitAnalyseHandlers = [
|
||||
// ==================== 静态文件路径 Handlers ====================
|
||||
|
||||
// 1. /data/zt/dates.json - 可用日期列表
|
||||
http.get('/data/zt/dates.json', async () => {
|
||||
await delay(200);
|
||||
console.log('[Mock LimitAnalyse] 获取 dates.json');
|
||||
const data = generateDatesJson();
|
||||
return HttpResponse.json(data);
|
||||
}),
|
||||
|
||||
// 2. /data/zt/daily/:date.json - 每日分析数据
|
||||
http.get('/data/zt/daily/:date', async ({ params }) => {
|
||||
await delay(300);
|
||||
// 移除 .json 后缀和查询参数
|
||||
const dateParam = params.date.replace('.json', '').split('?')[0];
|
||||
console.log('[Mock LimitAnalyse] 获取每日数据:', dateParam);
|
||||
const data = generateDailyJson(dateParam);
|
||||
return HttpResponse.json(data);
|
||||
}),
|
||||
|
||||
// 3. /data/zt/stocks.jsonl - 股票列表(用于搜索)
|
||||
http.get('/data/zt/stocks.jsonl', async () => {
|
||||
await delay(200);
|
||||
console.log('[Mock LimitAnalyse] 获取 stocks.jsonl');
|
||||
const stocks = generateStocksJsonl();
|
||||
// JSONL 格式:每行一个 JSON
|
||||
const jsonl = stocks.map(s => JSON.stringify(s)).join('\n');
|
||||
return new HttpResponse(jsonl, {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== API 路径 Handlers (兼容旧版本) ====================
|
||||
|
||||
// 1. 获取可用日期列表
|
||||
http.get('http://111.198.58.126:5001/api/v1/dates/available', async () => {
|
||||
await delay(300);
|
||||
|
||||
const availableDates = generateAvailableDates();
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
events: availableDates,
|
||||
message: '可用日期列表获取成功',
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 获取每日分析数据
|
||||
http.get('http://111.198.58.126:5001/api/v1/analysis/daily/:date', async ({ params }) => {
|
||||
await delay(500);
|
||||
|
||||
const { date } = params;
|
||||
const data = generateDailyAnalysis(date);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data,
|
||||
message: `${date} 每日分析数据获取成功`,
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 获取词云数据
|
||||
http.get('http://111.198.58.126:5001/api/v1/analysis/wordcloud/:date', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { date } = params;
|
||||
const wordCloudData = generateWordCloudData();
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: wordCloudData,
|
||||
message: `${date} 词云数据获取成功`,
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 混合搜索(POST)
|
||||
http.post('http://111.198.58.126:5001/api/v1/stocks/search/hybrid', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
const body = await request.json();
|
||||
const { query, type = 'all', mode = 'hybrid' } = body;
|
||||
|
||||
// 生成模拟搜索结果
|
||||
const results = [];
|
||||
const count = Math.floor(Math.random() * 10) + 5;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
results.push({
|
||||
code: `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`,
|
||||
name: `${query || '搜索'}相关股票${i + 1}`,
|
||||
sector: ['人工智能', 'ChatGPT', '新能源'][Math.floor(Math.random() * 3)],
|
||||
limit_date: new Date().toISOString().split('T')[0].replace(/-/g, ''),
|
||||
limit_time: `${Math.floor(Math.random() * 4) + 9}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`,
|
||||
price: (Math.random() * 100 + 10).toFixed(2),
|
||||
change_pct: (Math.random() * 10).toFixed(2),
|
||||
match_score: (Math.random() * 0.5 + 0.5).toFixed(2),
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
query: query,
|
||||
type: type,
|
||||
mode: mode,
|
||||
results: results,
|
||||
total: results.length,
|
||||
},
|
||||
message: '搜索完成',
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 获取高位股列表(涨停股票列表)
|
||||
http.get('http://111.198.58.126:5001/api/limit-analyse/high-position-stocks', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
console.log('[Mock LimitAnalyse] 获取高位股列表:', { date });
|
||||
|
||||
const stocks = generateHighPositionStocks();
|
||||
const statistics = generateHighPositionStatistics(stocks);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stocks: stocks,
|
||||
statistics: statistics,
|
||||
date: date,
|
||||
},
|
||||
message: '高位股数据获取成功',
|
||||
});
|
||||
}),
|
||||
];
|
||||
690
src/mocks/handlers/stock.js
Normal file
690
src/mocks/handlers/stock.js
Normal file
@@ -0,0 +1,690 @@
|
||||
// src/mocks/handlers/stock.js
|
||||
// 股票相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateTimelineData, generateDailyData } from '../data/kline';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 常用字拼音首字母映射(简化版)
|
||||
const PINYIN_MAP = {
|
||||
'平': 'p', '安': 'a', '银': 'y', '行': 'h', '浦': 'p', '发': 'f',
|
||||
'招': 'z', '商': 's', '兴': 'x', '业': 'y', '北': 'b', '京': 'j',
|
||||
'农': 'n', '交': 'j', '通': 't', '工': 'g', '光': 'g', '大': 'd',
|
||||
'建': 'j', '设': 's', '中': 'z', '信': 'x', '证': 'z', '券': 'q',
|
||||
'国': 'g', '金': 'j', '海': 'h', '华': 'h', '泰': 't', '方': 'f',
|
||||
'正': 'z', '新': 'x', '保': 'b', '险': 'x', '太': 't', '人': 'r',
|
||||
'寿': 's', '泸': 'l', '州': 'z', '老': 'l', '窖': 'j', '古': 'g',
|
||||
'井': 'j', '贡': 'g', '酒': 'j', '五': 'w', '粮': 'l', '液': 'y',
|
||||
'贵': 'g', '茅': 'm', '台': 't', '青': 'q', '岛': 'd', '啤': 'p',
|
||||
'水': 's', '坊': 'f', '今': 'j', '世': 's', '缘': 'y', '云': 'y',
|
||||
'南': 'n', '白': 'b', '药': 'y', '长': 'c', '春': 'c', '高': 'g',
|
||||
'科': 'k', '伦': 'l', '比': 'b', '亚': 'y', '迪': 'd', '恒': 'h',
|
||||
'瑞': 'r', '医': 'y', '片': 'p', '仔': 'z', '癀': 'h', '明': 'm',
|
||||
'康': 'k', '德': 'd', '讯': 'x', '东': 'd', '威': 'w', '视': 's',
|
||||
'立': 'l', '精': 'j', '密': 'm', '电': 'd', '航': 'h',
|
||||
'动': 'd', '力': 'l', '韦': 'w', '尔': 'e', '股': 'g', '份': 'f',
|
||||
'万': 'w', '赣': 'g', '锋': 'f', '锂': 'l', '宁': 'n', '时': 's',
|
||||
'代': 'd', '隆': 'l', '基': 'j', '绿': 'l', '能': 'n',
|
||||
'筑': 'z', '汽': 'q', '车': 'c', '宇': 'y', '客': 'k', '上': 's',
|
||||
'集': 'j', '团': 't', '广': 'g', '城': 'c', '侨': 'q', '夏': 'x',
|
||||
'幸': 'x', '福': 'f', '地': 'd', '控': 'k', '美': 'm', '格': 'g',
|
||||
'苏': 's', '智': 'z', '家': 'j', '易': 'y', '购': 'g',
|
||||
'轩': 'x', '财': 'c', '富': 'f', '石': 's', '化': 'h', '学': 'x',
|
||||
'山': 's', '黄': 'h', '螺': 'l', '泥': 'n', '神': 's', '油': 'y',
|
||||
'联': 'l', '移': 'y', '伊': 'y', '利': 'l', '紫': 'z', '矿': 'k',
|
||||
'天': 't', '味': 'w', '港': 'g', '微': 'w',
|
||||
'技': 'j', '的': 'd', '器': 'q', '泊': 'b', '铁': 't',
|
||||
};
|
||||
|
||||
// 生成拼音缩写
|
||||
const generatePinyinAbbr = (name) => {
|
||||
return name.split('').map(char => PINYIN_MAP[char] || '').join('');
|
||||
};
|
||||
|
||||
// 生成A股主要股票数据(包含各大指数成分股)
|
||||
const generateStockList = () => {
|
||||
const stocks = [
|
||||
// 银行
|
||||
{ code: '000001', name: '平安银行' },
|
||||
{ code: '600000', name: '浦发银行' },
|
||||
{ code: '600036', name: '招商银行' },
|
||||
{ code: '601166', name: '兴业银行' },
|
||||
{ code: '601169', name: '北京银行' },
|
||||
{ code: '601288', name: '农业银行' },
|
||||
{ code: '601328', name: '交通银行' },
|
||||
{ code: '601398', name: '工商银行' },
|
||||
{ code: '601818', name: '光大银行' },
|
||||
{ code: '601939', name: '建设银行' },
|
||||
{ code: '601998', name: '中信银行' },
|
||||
|
||||
// 证券
|
||||
{ code: '600030', name: '中信证券' },
|
||||
{ code: '600109', name: '国金证券' },
|
||||
{ code: '600837', name: '海通证券' },
|
||||
{ code: '600999', name: '招商证券' },
|
||||
{ code: '601688', name: '华泰证券' },
|
||||
{ code: '601901', name: '方正证券' },
|
||||
|
||||
// 保险
|
||||
{ code: '601318', name: '中国平安' },
|
||||
{ code: '601336', name: '新华保险' },
|
||||
{ code: '601601', name: '中国太保' },
|
||||
{ code: '601628', name: '中国人寿' },
|
||||
|
||||
// 白酒/食品饮料
|
||||
{ code: '000568', name: '泸州老窖' },
|
||||
{ code: '000596', name: '古井贡酒' },
|
||||
{ code: '000858', name: '五粮液' },
|
||||
{ code: '600519', name: '贵州茅台' },
|
||||
{ code: '600600', name: '青岛啤酒' },
|
||||
{ code: '600779', name: '水井坊' },
|
||||
{ code: '603369', name: '今世缘' },
|
||||
|
||||
// 医药
|
||||
{ code: '000538', name: '云南白药' },
|
||||
{ code: '000661', name: '长春高新' },
|
||||
{ code: '002422', name: '科伦药业' },
|
||||
{ code: '002594', name: '比亚迪' },
|
||||
{ code: '600276', name: '恒瑞医药' },
|
||||
{ code: '600436', name: '片仔癀' },
|
||||
{ code: '603259', name: '药明康德' },
|
||||
|
||||
// 科技/半导体
|
||||
{ code: '000063', name: '中兴通讯' },
|
||||
{ code: '000725', name: '京东方A' },
|
||||
{ code: '002049', name: '紫光国微' },
|
||||
{ code: '002415', name: '海康威视' },
|
||||
{ code: '002475', name: '立讯精密' },
|
||||
{ code: '600584', name: '长电科技' },
|
||||
{ code: '600893', name: '航发动力' },
|
||||
{ code: '603501', name: '韦尔股份' },
|
||||
|
||||
// 新能源/电力
|
||||
{ code: '000002', name: '万科A' },
|
||||
{ code: '002460', name: '赣锋锂业' },
|
||||
{ code: '300750', name: '宁德时代' },
|
||||
{ code: '600438', name: '通威股份' },
|
||||
{ code: '601012', name: '隆基绿能' },
|
||||
{ code: '601668', name: '中国建筑' },
|
||||
|
||||
// 汽车
|
||||
{ code: '000625', name: '长安汽车' },
|
||||
{ code: '600066', name: '宇通客车' },
|
||||
{ code: '600104', name: '上汽集团' },
|
||||
{ code: '601238', name: '广汽集团' },
|
||||
{ code: '601633', name: '长城汽车' },
|
||||
|
||||
// 地产
|
||||
{ code: '000002', name: '万科A' },
|
||||
{ code: '000069', name: '华侨城A' },
|
||||
{ code: '600340', name: '华夏幸福' },
|
||||
{ code: '600606', name: '绿地控股' },
|
||||
|
||||
// 家电
|
||||
{ code: '000333', name: '美的集团' },
|
||||
{ code: '000651', name: '格力电器' },
|
||||
{ code: '002032', name: '苏泊尔' },
|
||||
{ code: '600690', name: '海尔智家' },
|
||||
|
||||
// 互联网/电商
|
||||
{ code: '002024', name: '苏宁易购' },
|
||||
{ code: '002074', name: '国轩高科' },
|
||||
{ code: '300059', name: '东方财富' },
|
||||
|
||||
// 能源/化工
|
||||
{ code: '600028', name: '中国石化' },
|
||||
{ code: '600309', name: '万华化学' },
|
||||
{ code: '600547', name: '山东黄金' },
|
||||
{ code: '600585', name: '海螺水泥' },
|
||||
{ code: '601088', name: '中国神华' },
|
||||
{ code: '601857', name: '中国石油' },
|
||||
|
||||
// 电信/运营商
|
||||
{ code: '600050', name: '中国联通' },
|
||||
{ code: '600941', name: '中国移动' },
|
||||
{ code: '601728', name: '中国电信' },
|
||||
|
||||
// 其他蓝筹
|
||||
{ code: '600887', name: '伊利股份' },
|
||||
{ code: '601111', name: '中国国航' },
|
||||
{ code: '601390', name: '中国中铁' },
|
||||
{ code: '601899', name: '紫金矿业' },
|
||||
{ code: '603288', name: '海天味业' },
|
||||
];
|
||||
|
||||
// 添加拼音缩写
|
||||
return stocks.map(s => ({
|
||||
...s,
|
||||
pinyin_abbr: generatePinyinAbbr(s.name)
|
||||
}));
|
||||
};
|
||||
|
||||
// 股票相关的 Handlers
|
||||
export const stockHandlers = [
|
||||
// 搜索股票(个股中心页面使用)- 支持模糊搜索
|
||||
http.get('/api/stocks/search', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = (url.searchParams.get('q') || '').toLowerCase().trim();
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||
|
||||
console.log('[Mock Stock] 搜索股票:', { query, limit });
|
||||
|
||||
const stocks = generateStockList();
|
||||
|
||||
// 如果没有搜索词,返回空结果
|
||||
if (!query) {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
// 模糊搜索:代码 + 名称 + 拼音缩写(不区分大小写)
|
||||
const results = stocks.filter(s => {
|
||||
const code = s.code.toLowerCase();
|
||||
const name = s.name.toLowerCase();
|
||||
const pinyin = (s.pinyin_abbr || '').toLowerCase();
|
||||
return code.includes(query) || name.includes(query) || pinyin.includes(query);
|
||||
});
|
||||
|
||||
// 按相关性排序:完全匹配 > 开头匹配 > 包含匹配
|
||||
results.sort((a, b) => {
|
||||
const aCode = a.code.toLowerCase();
|
||||
const bCode = b.code.toLowerCase();
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
const aPinyin = (a.pinyin_abbr || '').toLowerCase();
|
||||
const bPinyin = (b.pinyin_abbr || '').toLowerCase();
|
||||
|
||||
// 计算匹配分数(包含拼音匹配)
|
||||
const getScore = (code, name, pinyin) => {
|
||||
if (code === query || name === query || pinyin === query) return 100; // 完全匹配
|
||||
if (code.startsWith(query)) return 80; // 代码开头
|
||||
if (pinyin.startsWith(query)) return 70; // 拼音开头
|
||||
if (name.startsWith(query)) return 60; // 名称开头
|
||||
if (code.includes(query)) return 40; // 代码包含
|
||||
if (pinyin.includes(query)) return 30; // 拼音包含
|
||||
if (name.includes(query)) return 20; // 名称包含
|
||||
return 0;
|
||||
};
|
||||
|
||||
return getScore(bCode, bName, bPinyin) - getScore(aCode, aName, aPinyin);
|
||||
});
|
||||
|
||||
// 返回格式化数据
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: results.slice(0, limit).map(s => ({
|
||||
stock_code: s.code,
|
||||
stock_name: s.name,
|
||||
pinyin_abbr: s.pinyin_abbr,
|
||||
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
||||
industry: ['银行', '证券', '保险', '白酒', '医药', '科技', '新能源', '汽车', '地产', '家电'][Math.floor(Math.random() * 10)],
|
||||
change_pct: parseFloat((Math.random() * 10 - 3).toFixed(2)),
|
||||
price: parseFloat((Math.random() * 100 + 5).toFixed(2))
|
||||
}))
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取所有股票列表
|
||||
http.get('/api/stocklist', async () => {
|
||||
await delay(200);
|
||||
|
||||
try {
|
||||
const stocks = generateStockList();
|
||||
|
||||
// console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length }); // 已关闭:减少日志
|
||||
|
||||
return HttpResponse.json(stocks);
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取股票列表失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '获取股票列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取指数K线数据
|
||||
http.get('/api/index/:indexCode/kline', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
|
||||
const { indexCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const type = url.searchParams.get('type') || 'timeline';
|
||||
const eventTime = url.searchParams.get('event_time');
|
||||
|
||||
console.log('[Mock Stock] 获取指数K线数据:', { indexCode, type, eventTime });
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (type === 'timeline' || type === 'minute') {
|
||||
// timeline 和 minute 都使用分时数据
|
||||
data = generateTimelineData(indexCode);
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData(indexCode, 30);
|
||||
} else {
|
||||
// 其他类型也降级使用 timeline 数据
|
||||
console.log('[Mock Stock] 未知类型,降级使用 timeline:', type);
|
||||
data = generateTimelineData(indexCode);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data,
|
||||
index_code: indexCode,
|
||||
type: type,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取K线数据失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '获取K线数据失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票K线数据
|
||||
http.get('/api/stock/:stockCode/kline', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const type = url.searchParams.get('type') || 'timeline';
|
||||
const eventTime = url.searchParams.get('event_time');
|
||||
|
||||
console.log('[Mock Stock] 获取股票K线数据:', { stockCode, type, eventTime });
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (type === 'timeline') {
|
||||
// 股票使用指数的数据生成逻辑,但价格基数不同
|
||||
data = generateTimelineData('000001.SH'); // 可以根据股票代码调整
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData('000001.SH', 30);
|
||||
} else {
|
||||
return HttpResponse.json(
|
||||
{ error: '不支持的类型' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data,
|
||||
stock_code: stockCode,
|
||||
type: type,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取股票K线数据失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '获取K线数据失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 批量获取股票K线数据
|
||||
http.post('/api/stock/batch-kline', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { codes, type = 'timeline', event_time } = body;
|
||||
|
||||
console.log('[Mock Stock] 批量获取K线数据:', {
|
||||
stockCount: codes?.length,
|
||||
type,
|
||||
eventTime: event_time
|
||||
});
|
||||
|
||||
if (!codes || !Array.isArray(codes) || codes.length === 0) {
|
||||
return HttpResponse.json(
|
||||
{ error: '股票代码列表不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 为每只股票生成数据
|
||||
const batchData = {};
|
||||
codes.forEach(stockCode => {
|
||||
let data;
|
||||
if (type === 'timeline') {
|
||||
data = generateTimelineData('000001.SH');
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData('000001.SH', 60);
|
||||
} else {
|
||||
data = [];
|
||||
}
|
||||
|
||||
batchData[stockCode] = {
|
||||
success: true,
|
||||
data: data,
|
||||
stock_code: stockCode
|
||||
};
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: batchData,
|
||||
type: type,
|
||||
message: '批量获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 批量获取K线数据失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '批量获取K线数据失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票业绩预告
|
||||
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { stockCode } = params;
|
||||
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
|
||||
|
||||
// 生成股票列表用于查找名称
|
||||
const stockList = generateStockList();
|
||||
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
|
||||
const stockName = stockInfo?.name || `股票${stockCode}`;
|
||||
|
||||
// 业绩预告类型列表
|
||||
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
|
||||
|
||||
// 生成业绩预告数据
|
||||
const forecasts = [
|
||||
{
|
||||
forecast_type: '预增',
|
||||
report_date: '2024年年报',
|
||||
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元,同比增长10%至17%。`,
|
||||
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
|
||||
change_range: {
|
||||
lower: 10,
|
||||
upper: 17
|
||||
},
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
{
|
||||
forecast_type: '略增',
|
||||
report_date: '2024年三季报',
|
||||
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元,同比增长5%至12%。`,
|
||||
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
|
||||
change_range: {
|
||||
lower: 5,
|
||||
upper: 12
|
||||
},
|
||||
publish_date: '2024-07-12'
|
||||
},
|
||||
{
|
||||
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
|
||||
report_date: '2024年中报',
|
||||
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
|
||||
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
|
||||
change_range: {
|
||||
lower: 3,
|
||||
upper: 8
|
||||
},
|
||||
publish_date: '2024-04-20'
|
||||
}
|
||||
];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
forecasts: forecasts
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取股票报价(批量)
|
||||
http.post('/api/stock/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { codes, event_time } = body;
|
||||
|
||||
console.log('[Mock Stock] 获取股票报价:', {
|
||||
stockCount: codes?.length,
|
||||
eventTime: event_time
|
||||
});
|
||||
|
||||
if (!codes || !Array.isArray(codes) || codes.length === 0) {
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '股票代码列表不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成股票列表用于查找名称
|
||||
const stockList = generateStockList();
|
||||
const stockMap = {};
|
||||
stockList.forEach(s => {
|
||||
stockMap[s.code] = s.name;
|
||||
});
|
||||
|
||||
// 行业和指数映射表
|
||||
const stockIndustryMap = {
|
||||
'000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] },
|
||||
'600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] },
|
||||
'300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] },
|
||||
'601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] },
|
||||
'600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] },
|
||||
'000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] },
|
||||
'002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] },
|
||||
};
|
||||
|
||||
const defaultIndustries = [
|
||||
{ industry_l1: '科技', industry: '软件' },
|
||||
{ industry_l1: '医药', industry: '化学制药' },
|
||||
{ industry_l1: '消费', industry: '食品' },
|
||||
{ industry_l1: '金融', industry: '证券' },
|
||||
{ industry_l1: '工业', industry: '机械' },
|
||||
];
|
||||
|
||||
// 为每只股票生成报价数据
|
||||
const quotesData = {};
|
||||
codes.forEach(stockCode => {
|
||||
// 生成基础价格(10-200之间)
|
||||
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
|
||||
// 涨跌幅(-10% 到 +10%)
|
||||
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
|
||||
// 涨跌额
|
||||
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
|
||||
// 昨收
|
||||
const prevClose = parseFloat((basePrice - change).toFixed(2));
|
||||
|
||||
// 获取行业和指数信息
|
||||
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
|
||||
const industryInfo = stockIndustryMap[codeWithoutSuffix] ||
|
||||
defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)];
|
||||
|
||||
quotesData[stockCode] = {
|
||||
code: stockCode,
|
||||
name: stockMap[stockCode] || `股票${stockCode}`,
|
||||
price: basePrice,
|
||||
change: change,
|
||||
change_percent: changePercent,
|
||||
prev_close: prevClose,
|
||||
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
|
||||
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
|
||||
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 100000000),
|
||||
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
|
||||
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
|
||||
update_time: new Date().toISOString(),
|
||||
// 行业和指数标签
|
||||
industry_l1: industryInfo.industry_l1,
|
||||
industry: industryInfo.industry,
|
||||
index_tags: industryInfo.index_tags || [],
|
||||
// 关键指标
|
||||
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
|
||||
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
|
||||
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
|
||||
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
|
||||
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
|
||||
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
|
||||
// 主力动态
|
||||
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
|
||||
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
|
||||
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
|
||||
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
|
||||
};
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: quotesData,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取股票报价失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '获取股票报价失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票详细行情(quote-detail)
|
||||
http.get('/api/stock/:stockCode/quote-detail', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { stockCode } = params;
|
||||
console.log('[Mock Stock] 获取股票详细行情:', { stockCode });
|
||||
|
||||
const stocks = generateStockList();
|
||||
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
|
||||
const stockInfo = stocks.find(s => s.code === codeWithoutSuffix);
|
||||
const stockName = stockInfo?.name || `股票${stockCode}`;
|
||||
|
||||
// 生成基础价格(10-200之间)
|
||||
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
|
||||
// 涨跌幅(-10% 到 +10%)
|
||||
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
|
||||
// 涨跌额
|
||||
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
|
||||
// 昨收
|
||||
const prevClose = parseFloat((basePrice - change).toFixed(2));
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
price: basePrice,
|
||||
change: change,
|
||||
change_percent: changePercent,
|
||||
prev_close: prevClose,
|
||||
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
|
||||
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
|
||||
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 100000000),
|
||||
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
|
||||
turnover_rate: parseFloat((Math.random() * 10).toFixed(2)),
|
||||
amplitude: parseFloat((Math.random() * 8).toFixed(2)),
|
||||
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
|
||||
update_time: new Date().toISOString(),
|
||||
// 买卖盘口
|
||||
bid1: parseFloat((basePrice * 0.998).toFixed(2)),
|
||||
bid1_volume: Math.floor(Math.random() * 10000),
|
||||
bid2: parseFloat((basePrice * 0.996).toFixed(2)),
|
||||
bid2_volume: Math.floor(Math.random() * 10000),
|
||||
bid3: parseFloat((basePrice * 0.994).toFixed(2)),
|
||||
bid3_volume: Math.floor(Math.random() * 10000),
|
||||
bid4: parseFloat((basePrice * 0.992).toFixed(2)),
|
||||
bid4_volume: Math.floor(Math.random() * 10000),
|
||||
bid5: parseFloat((basePrice * 0.990).toFixed(2)),
|
||||
bid5_volume: Math.floor(Math.random() * 10000),
|
||||
ask1: parseFloat((basePrice * 1.002).toFixed(2)),
|
||||
ask1_volume: Math.floor(Math.random() * 10000),
|
||||
ask2: parseFloat((basePrice * 1.004).toFixed(2)),
|
||||
ask2_volume: Math.floor(Math.random() * 10000),
|
||||
ask3: parseFloat((basePrice * 1.006).toFixed(2)),
|
||||
ask3_volume: Math.floor(Math.random() * 10000),
|
||||
ask4: parseFloat((basePrice * 1.008).toFixed(2)),
|
||||
ask4_volume: Math.floor(Math.random() * 10000),
|
||||
ask5: parseFloat((basePrice * 1.010).toFixed(2)),
|
||||
ask5_volume: Math.floor(Math.random() * 10000),
|
||||
// 关键指标
|
||||
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
|
||||
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
|
||||
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
|
||||
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
|
||||
circulating_market_cap: `${(Math.random() * 3000 + 50).toFixed(0)}亿`,
|
||||
total_shares: `${(Math.random() * 100 + 10).toFixed(2)}亿`,
|
||||
circulating_shares: `${(Math.random() * 80 + 5).toFixed(2)}亿`,
|
||||
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
|
||||
week52_low: parseFloat((basePrice * 0.7).toFixed(2))
|
||||
},
|
||||
message: '获取成功'
|
||||
});
|
||||
}),
|
||||
|
||||
// FlexScreen 行情数据
|
||||
http.get('/api/flex-screen/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const codes = url.searchParams.get('codes')?.split(',') || [];
|
||||
|
||||
console.log('[Mock Stock] 获取 FlexScreen 行情:', { codes });
|
||||
|
||||
// 默认主要指数
|
||||
const defaultIndices = ['000001', '399001', '399006'];
|
||||
const targetCodes = codes.length > 0 ? codes : defaultIndices;
|
||||
|
||||
const indexData = {
|
||||
'000001': { name: '上证指数', basePrice: 3200 },
|
||||
'399001': { name: '深证成指', basePrice: 10500 },
|
||||
'399006': { name: '创业板指', basePrice: 2100 },
|
||||
'000300': { name: '沪深300', basePrice: 3800 },
|
||||
'000016': { name: '上证50', basePrice: 2600 },
|
||||
'000905': { name: '中证500', basePrice: 5800 },
|
||||
};
|
||||
|
||||
const quotesData = {};
|
||||
targetCodes.forEach(code => {
|
||||
const codeWithoutSuffix = code.replace(/\.(SH|SZ)$/i, '');
|
||||
const info = indexData[codeWithoutSuffix] || { name: `指数${code}`, basePrice: 3000 };
|
||||
|
||||
const changePercent = parseFloat((Math.random() * 4 - 2).toFixed(2));
|
||||
const price = parseFloat((info.basePrice * (1 + changePercent / 100)).toFixed(2));
|
||||
const change = parseFloat((price - info.basePrice).toFixed(2));
|
||||
|
||||
quotesData[code] = {
|
||||
code: code,
|
||||
name: info.name,
|
||||
price: price,
|
||||
change: change,
|
||||
change_percent: changePercent,
|
||||
prev_close: info.basePrice,
|
||||
open: parseFloat((info.basePrice * (1 + (Math.random() * 0.01 - 0.005))).toFixed(2)),
|
||||
high: parseFloat((price * (1 + Math.random() * 0.01)).toFixed(2)),
|
||||
low: parseFloat((price * (1 - Math.random() * 0.01)).toFixed(2)),
|
||||
volume: parseFloat((Math.random() * 5000 + 2000).toFixed(2)), // 亿手
|
||||
amount: parseFloat((Math.random() * 8000 + 3000).toFixed(2)), // 亿元
|
||||
update_time: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: quotesData,
|
||||
message: '获取成功'
|
||||
});
|
||||
}),
|
||||
];
|
||||
251
src/services/invoiceService.ts
Normal file
251
src/services/invoiceService.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 发票服务
|
||||
* 处理发票申请、查询、下载等操作
|
||||
*/
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import type {
|
||||
InvoiceInfo,
|
||||
CreateInvoiceRequest,
|
||||
InvoiceTitleTemplate,
|
||||
InvoiceableOrder,
|
||||
InvoiceListParams,
|
||||
InvoiceStats,
|
||||
} from '@/types/invoice';
|
||||
import type { ApiResponse } from '@/types/api';
|
||||
|
||||
/** 发票列表分页响应 */
|
||||
interface InvoiceListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: InvoiceInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可开票订单列表
|
||||
* 返回已支付且未申请开票的订单
|
||||
*/
|
||||
export const getAvailableOrders = async (): Promise<ApiResponse<InvoiceableOrder[]>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/available-orders`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 申请开票
|
||||
*/
|
||||
export const applyInvoice = async (
|
||||
data: CreateInvoiceRequest
|
||||
): Promise<ApiResponse<InvoiceInfo>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/apply`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票列表
|
||||
*/
|
||||
export const getInvoiceList = async (
|
||||
params?: InvoiceListParams
|
||||
): Promise<InvoiceListResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.set('page', params.page.toString());
|
||||
if (params?.pageSize) searchParams.set('pageSize', params.pageSize.toString());
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${getApiBase()}/api/invoice/list${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票详情
|
||||
*/
|
||||
export const getInvoiceDetail = async (invoiceId: string): Promise<ApiResponse<InvoiceInfo>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/${invoiceId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消发票申请
|
||||
*/
|
||||
export const cancelInvoice = async (invoiceId: string): Promise<ApiResponse<null>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/${invoiceId}/cancel`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载电子发票
|
||||
*/
|
||||
export const downloadInvoice = async (invoiceId: string): Promise<Blob> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/${invoiceId}/download`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票统计信息
|
||||
*/
|
||||
export const getInvoiceStats = async (): Promise<ApiResponse<InvoiceStats>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/stats`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取发票抬头模板列表
|
||||
*/
|
||||
export const getTitleTemplates = async (): Promise<ApiResponse<InvoiceTitleTemplate[]>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-templates`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存发票抬头模板
|
||||
*/
|
||||
export const saveTitleTemplate = async (
|
||||
data: Omit<InvoiceTitleTemplate, 'id' | 'userId' | 'createdAt'>
|
||||
): Promise<ApiResponse<InvoiceTitleTemplate>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-template`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除发票抬头模板
|
||||
*/
|
||||
export const deleteTitleTemplate = async (templateId: string): Promise<ApiResponse<null>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-template/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置默认发票抬头
|
||||
*/
|
||||
export const setDefaultTemplate = async (templateId: string): Promise<ApiResponse<null>> => {
|
||||
const response = await fetch(`${getApiBase()}/api/invoice/title-template/${templateId}/default`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
278
src/services/llmService.js
Normal file
278
src/services/llmService.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// src/services/llmService.js
|
||||
// LLM服务层 - 集成AI模型进行对话和工具调用
|
||||
|
||||
import axios from 'axios';
|
||||
import { mcpService } from './mcpService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* LLM服务配置
|
||||
*/
|
||||
const LLM_CONFIG = {
|
||||
// 可以使用 OpenAI、Claude、通义千问等
|
||||
provider: 'openai', // 或 'claude', 'qwen'
|
||||
apiKey: process.env.REACT_APP_OPENAI_API_KEY || '',
|
||||
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
model: 'gpt-4o-mini', // 更便宜的模型
|
||||
};
|
||||
|
||||
/**
|
||||
* LLM服务类
|
||||
*/
|
||||
class LLMService {
|
||||
constructor() {
|
||||
this.conversationHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建系统提示词
|
||||
*/
|
||||
getSystemPrompt(availableTools) {
|
||||
return `你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
|
||||
|
||||
${availableTools.map(tool => `
|
||||
**${tool.name}**
|
||||
描述:${tool.description}
|
||||
参数:${JSON.stringify(tool.parameters, null, 2)}
|
||||
`).join('\n')}
|
||||
|
||||
用户提问时,请按照以下步骤:
|
||||
1. 理解用户的意图
|
||||
2. 选择合适的工具(可以多个)
|
||||
3. 提取工具需要的参数
|
||||
4. 调用工具后,用自然语言总结结果
|
||||
|
||||
回复格式:
|
||||
- 如果需要调用工具,返回JSON格式:{"tool": "工具名", "arguments": {...}}
|
||||
- 如果不需要工具,直接回复自然语言
|
||||
|
||||
注意:
|
||||
- 贵州茅台的股票代码是 600519
|
||||
- 涨停是指股票当日涨幅达到10%
|
||||
- 概念板块是指相同题材的股票分类`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能对话 - 使用LLM理解意图并调用工具
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
// 1. 获取可用工具列表
|
||||
const toolsResult = await mcpService.listTools();
|
||||
if (!toolsResult.success) {
|
||||
throw new Error('获取工具列表失败');
|
||||
}
|
||||
|
||||
const availableTools = toolsResult.data;
|
||||
|
||||
// 2. 构建对话历史
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: this.getSystemPrompt(availableTools),
|
||||
},
|
||||
...conversationHistory.map(msg => ({
|
||||
role: msg.isUser ? 'user' : 'assistant',
|
||||
content: msg.content,
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
},
|
||||
];
|
||||
|
||||
// 3. 调用LLM
|
||||
logger.info('LLMService', '调用LLM', { messageCount: messages.length });
|
||||
|
||||
// 注意:这里需要配置API密钥
|
||||
if (!LLM_CONFIG.apiKey) {
|
||||
// 如果没有配置LLM,使用简单的关键词匹配
|
||||
logger.warn('LLMService', '未配置LLM API密钥,使用简单匹配');
|
||||
return await this.fallbackChat(userMessage);
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
LLM_CONFIG.apiUrl,
|
||||
{
|
||||
model: LLM_CONFIG.model,
|
||||
messages: messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const aiResponse = response.data.choices[0].message.content;
|
||||
logger.info('LLMService', 'LLM响应', { response: aiResponse });
|
||||
|
||||
// 4. 解析LLM响应
|
||||
// 如果LLM返回工具调用指令
|
||||
try {
|
||||
const toolCall = JSON.parse(aiResponse);
|
||||
if (toolCall.tool && toolCall.arguments) {
|
||||
// 调用MCP工具
|
||||
const toolResult = await mcpService.callTool(toolCall.tool, toolCall.arguments);
|
||||
|
||||
if (!toolResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: toolResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 让LLM总结工具结果
|
||||
const summaryMessages = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: aiResponse,
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
content: `工具 ${toolCall.tool} 返回的数据:\n${JSON.stringify(toolResult.data, null, 2)}\n\n请用自然语言总结这些数据,给用户一个简洁清晰的回复。`,
|
||||
},
|
||||
];
|
||||
|
||||
const summaryResponse = await axios.post(
|
||||
LLM_CONFIG.apiUrl,
|
||||
{
|
||||
model: LLM_CONFIG.model,
|
||||
messages: summaryMessages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 500,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const summary = summaryResponse.data.choices[0].message.content;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: summary,
|
||||
rawData: toolResult.data,
|
||||
toolUsed: toolCall.tool,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
// 不是JSON格式,说明是直接回复
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: aiResponse,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 默认返回LLM的直接回复
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: aiResponse,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('LLMService', 'chat error', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级方案:简单的关键词匹配(当没有配置LLM时)
|
||||
*/
|
||||
async fallbackChat(userMessage) {
|
||||
logger.info('LLMService', '使用降级方案', { message: userMessage });
|
||||
|
||||
// 使用原有的简单匹配逻辑
|
||||
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
|
||||
const result = await mcpService.callTool('search_china_news', {
|
||||
query: userMessage.replace(/新闻|资讯/g, '').trim(),
|
||||
top_k: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '新闻搜索');
|
||||
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
|
||||
const query = userMessage.replace(/概念|板块/g, '').trim();
|
||||
const result = await mcpService.callTool('search_concepts', {
|
||||
query,
|
||||
size: 5,
|
||||
sort_by: 'change_pct',
|
||||
});
|
||||
return this.formatFallbackResponse(result, '概念搜索');
|
||||
} else if (userMessage.includes('涨停')) {
|
||||
const query = userMessage.replace(/涨停/g, '').trim();
|
||||
const result = await mcpService.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
page_size: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '涨停分析');
|
||||
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
|
||||
// 6位数字 = 股票代码
|
||||
const result = await mcpService.callTool('get_stock_basic_info', {
|
||||
seccode: userMessage.trim(),
|
||||
});
|
||||
return this.formatFallbackResponse(result, '股票信息');
|
||||
} else if (userMessage.includes('茅台') || userMessage.includes('贵州茅台')) {
|
||||
// 特殊处理茅台
|
||||
const result = await mcpService.callTool('get_stock_basic_info', {
|
||||
seccode: '600519',
|
||||
});
|
||||
return this.formatFallbackResponse(result, '贵州茅台股票信息');
|
||||
} else {
|
||||
// 默认:搜索新闻
|
||||
const result = await mcpService.callTool('search_china_news', {
|
||||
query: userMessage,
|
||||
top_k: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '新闻搜索');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化降级响应
|
||||
*/
|
||||
formatFallbackResponse(result, action) {
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: `已为您完成${action},找到以下结果:`,
|
||||
rawData: result.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除对话历史
|
||||
*/
|
||||
clearHistory() {
|
||||
this.conversationHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const llmService = new LLMService();
|
||||
|
||||
export default LLMService;
|
||||
392
src/services/ztStaticService.js
Normal file
392
src/services/ztStaticService.js
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 涨停分析静态数据服务
|
||||
* 从 /data/zt/ 目录读取预生成的 JSON 文件
|
||||
* 不依赖后端 API,适合静态部署
|
||||
*/
|
||||
|
||||
// 数据基础路径
|
||||
const DATA_BASE_URL = '/data/zt';
|
||||
|
||||
// 缓存过期时间(毫秒)- dates.json 缓存5分钟,daily数据缓存30分钟
|
||||
const CACHE_TTL = {
|
||||
dates: 5 * 60 * 1000, // 5分钟
|
||||
daily: 30 * 60 * 1000, // 30分钟
|
||||
stocksJsonl: 60 * 60 * 1000, // 1小时
|
||||
};
|
||||
|
||||
// 内存缓存(带过期时间)
|
||||
const cache = {
|
||||
dates: null,
|
||||
datesTimestamp: 0,
|
||||
daily: new Map(),
|
||||
dailyTimestamps: new Map(),
|
||||
stocksJsonl: null,
|
||||
stocksJsonlTimestamp: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查缓存是否过期
|
||||
*/
|
||||
const isCacheExpired = (timestamp, ttl) => {
|
||||
return Date.now() - timestamp > ttl;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取可用日期列表
|
||||
* @param {boolean} forceRefresh - 是否强制刷新缓存
|
||||
*/
|
||||
export const fetchAvailableDates = async (forceRefresh = false) => {
|
||||
try {
|
||||
// 使用缓存(未过期且非强制刷新)
|
||||
if (!forceRefresh && cache.dates && !isCacheExpired(cache.datesTimestamp, CACHE_TTL.dates)) {
|
||||
console.log('[ztStaticService] fetchAvailableDates: using cache');
|
||||
return { success: true, events: cache.dates, from_cache: true };
|
||||
}
|
||||
|
||||
console.log('[ztStaticService] fetchAvailableDates: fetching from server');
|
||||
const response = await fetch(`${DATA_BASE_URL}/dates.json?t=${Date.now()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 转换为日历事件格式
|
||||
const events = (data.dates || []).map(d => ({
|
||||
title: `${d.count}只`,
|
||||
start: d.formatted_date,
|
||||
end: d.formatted_date,
|
||||
className: 'bg-gradient-primary',
|
||||
allDay: true,
|
||||
date: d.date,
|
||||
count: d.count,
|
||||
}));
|
||||
|
||||
// 缓存结果
|
||||
cache.dates = events;
|
||||
cache.datesTimestamp = Date.now();
|
||||
|
||||
return { success: true, events, total: events.length, from_cache: false };
|
||||
} catch (error) {
|
||||
console.error('[ztStaticService] fetchAvailableDates error:', error);
|
||||
return { success: false, error: error.message, events: [] };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定日期的分析数据
|
||||
* @param {string} date - 日期(YYYYMMDD格式)
|
||||
* @param {boolean} forceRefresh - 是否强制刷新缓存
|
||||
*/
|
||||
export const fetchDailyAnalysis = async (date, forceRefresh = false) => {
|
||||
try {
|
||||
// 使用缓存(未过期且非强制刷新)
|
||||
const cachedTimestamp = cache.dailyTimestamps.get(date);
|
||||
if (!forceRefresh && cache.daily.has(date) && cachedTimestamp && !isCacheExpired(cachedTimestamp, CACHE_TTL.daily)) {
|
||||
return { success: true, data: cache.daily.get(date), from_cache: true };
|
||||
}
|
||||
|
||||
const response = await fetch(`${DATA_BASE_URL}/daily/${date}.json?t=${Date.now()}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return { success: false, error: `日期 ${date} 的数据不存在` };
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
// 转换数据结构:将 stock_codes 替换为完整的 stocks 对象数组
|
||||
// 创建股票代码到股票对象的映射
|
||||
const stockMap = {};
|
||||
(rawData.stocks || []).forEach(stock => {
|
||||
stockMap[stock.scode] = stock;
|
||||
});
|
||||
|
||||
// 转换 sector_data 中的 stock_codes 为 stocks
|
||||
const transformedSectorData = {};
|
||||
if (rawData.sector_data) {
|
||||
Object.entries(rawData.sector_data).forEach(([sectorName, sectorInfo]) => {
|
||||
const stockCodes = sectorInfo.stock_codes || [];
|
||||
const stocks = stockCodes
|
||||
.map(code => stockMap[code])
|
||||
.filter(Boolean); // 过滤掉未找到的股票
|
||||
|
||||
transformedSectorData[sectorName] = {
|
||||
count: sectorInfo.count,
|
||||
stocks: stocks,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const data = {
|
||||
...rawData,
|
||||
sector_data: transformedSectorData,
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
cache.daily.set(date, data);
|
||||
cache.dailyTimestamps.set(date, Date.now());
|
||||
|
||||
return { success: true, data, from_cache: false };
|
||||
} catch (error) {
|
||||
console.error('[ztStaticService] fetchDailyAnalysis error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取词云数据
|
||||
* 从每日分析数据中提取
|
||||
*/
|
||||
export const fetchWordCloudData = async (date) => {
|
||||
try {
|
||||
const result = await fetchDailyAnalysis(date);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const wordFreqData = result.data.word_freq_data || [];
|
||||
return { success: true, data: wordFreqData };
|
||||
} catch (error) {
|
||||
console.error('[ztStaticService] fetchWordCloudData error:', error);
|
||||
return { success: false, error: error.message, data: [] };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取高位股统计
|
||||
* 从每日分析数据中的 stocks 计算
|
||||
*/
|
||||
export const fetchHighPositionStocks = async (date) => {
|
||||
try {
|
||||
const result = await fetchDailyAnalysis(date);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const stocks = result.data.stocks || [];
|
||||
|
||||
// 筛选连板股(continuous_days 包含数字 >= 2)
|
||||
const highPositionStocks = stocks
|
||||
.filter(stock => {
|
||||
const days = parseContinuousDays(stock.continuous_days);
|
||||
return days >= 2;
|
||||
})
|
||||
.map(stock => {
|
||||
const days = parseContinuousDays(stock.continuous_days);
|
||||
return {
|
||||
stock_code: stock.scode,
|
||||
stock_name: stock.sname,
|
||||
price: '-', // 静态数据中没有实时价格
|
||||
increase_rate: 10.0, // 涨停固定 10%
|
||||
continuous_limit_up: days,
|
||||
industry: (stock.core_sectors || [])[0] || '未知',
|
||||
turnover_rate: '-', // 静态数据中没有换手率
|
||||
brief: stock.brief || '',
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.continuous_limit_up - a.continuous_limit_up);
|
||||
|
||||
// 计算统计数据
|
||||
const totalCount = highPositionStocks.length;
|
||||
const maxDays = highPositionStocks.length > 0
|
||||
? Math.max(...highPositionStocks.map(s => s.continuous_limit_up))
|
||||
: 0;
|
||||
const avgDays = highPositionStocks.length > 0
|
||||
? (highPositionStocks.reduce((sum, s) => sum + s.continuous_limit_up, 0) / totalCount).toFixed(1)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
stocks: highPositionStocks,
|
||||
statistics: {
|
||||
total_count: totalCount,
|
||||
max_continuous_days: maxDays,
|
||||
avg_continuous_days: avgDays,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ztStaticService] fetchHighPositionStocks error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析连板天数
|
||||
* 例如 "2连板" -> 2, "首板" -> 1
|
||||
*/
|
||||
const parseContinuousDays = (str) => {
|
||||
if (!str) return 1;
|
||||
const match = str.match(/(\d+)/);
|
||||
if (match) {
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
if (str.includes('首板')) return 1;
|
||||
return 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载 stocks.jsonl 文件
|
||||
* JSONL 格式:每行一个 JSON 对象
|
||||
*/
|
||||
const loadStocksJsonl = async () => {
|
||||
try {
|
||||
// 使用缓存
|
||||
if (cache.stocksJsonl) {
|
||||
return cache.stocksJsonl;
|
||||
}
|
||||
|
||||
const response = await fetch(`${DATA_BASE_URL}/stocks.jsonl`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.trim().split('\n');
|
||||
const stocks = lines
|
||||
.filter(line => line.trim())
|
||||
.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// 缓存结果
|
||||
cache.stocksJsonl = stocks;
|
||||
|
||||
return stocks;
|
||||
} catch (error) {
|
||||
console.error('[ztStaticService] loadStocksJsonl error:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 简化版搜索股票
|
||||
* 仅支持股票代码或名称的精确/部分匹配
|
||||
* 使用 stocks.jsonl 作为数据源
|
||||
*/
|
||||
export const searchStocks = async (searchParams) => {
|
||||
try {
|
||||
const { query, page = 1, page_size = 50 } = searchParams;
|
||||
|
||||
if (!query || query.trim() === '') {
|
||||
return { success: false, error: '请输入股票代码或名称' };
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase().trim();
|
||||
|
||||
// 加载 stocks.jsonl 数据
|
||||
const allStocks = await loadStocksJsonl();
|
||||
|
||||
if (allStocks.length === 0) {
|
||||
return { success: false, error: '搜索数据暂未加载,请稍后重试' };
|
||||
}
|
||||
|
||||
// 简单的股票代码/名称匹配
|
||||
const results = allStocks
|
||||
.map(stock => {
|
||||
let score = 0;
|
||||
const scode = (stock.scode || '').toLowerCase();
|
||||
const sname = (stock.sname || '').toLowerCase();
|
||||
|
||||
// 精确匹配股票代码(最高优先级)
|
||||
if (scode === queryLower) {
|
||||
score = 100;
|
||||
}
|
||||
// 精确匹配股票名称
|
||||
else if (sname === queryLower) {
|
||||
score = 90;
|
||||
}
|
||||
// 股票代码以搜索词开头
|
||||
else if (scode.startsWith(queryLower)) {
|
||||
score = 80;
|
||||
}
|
||||
// 股票名称包含搜索词
|
||||
else if (sname.includes(queryLower)) {
|
||||
score = 70;
|
||||
}
|
||||
// 股票代码包含搜索词
|
||||
else if (scode.includes(queryLower)) {
|
||||
score = 60;
|
||||
}
|
||||
|
||||
return { ...stock, _score: score };
|
||||
})
|
||||
.filter(s => s._score > 0)
|
||||
.sort((a, b) => {
|
||||
// 先按匹配度排序,再按日期降序
|
||||
if (b._score !== a._score) {
|
||||
return b._score - a._score;
|
||||
}
|
||||
return (b.date || '').localeCompare(a.date || '');
|
||||
});
|
||||
|
||||
// 分页
|
||||
const total = results.length;
|
||||
const start = (page - 1) * page_size;
|
||||
const pageResults = results.slice(start, start + page_size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
stocks: pageResults,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages: Math.ceil(total / page_size),
|
||||
search_mode: 'exact',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ztStaticService] searchStocks error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量获取股票详情
|
||||
*/
|
||||
export const fetchStocksBatchDetail = async (codes, date) => {
|
||||
try {
|
||||
const result = await fetchDailyAnalysis(date);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const stocks = (result.data.stocks || []).filter(s => codes.includes(s.scode));
|
||||
return { success: true, data: stocks };
|
||||
} catch (error) {
|
||||
console.error('[ztStaticService] fetchStocksBatchDetail error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
export const clearCache = () => {
|
||||
cache.dates = null;
|
||||
cache.datesTimestamp = 0;
|
||||
cache.daily.clear();
|
||||
cache.dailyTimestamps.clear();
|
||||
cache.stocksJsonl = null;
|
||||
cache.stocksJsonlTimestamp = 0;
|
||||
console.log('[ztStaticService] Cache cleared');
|
||||
};
|
||||
|
||||
export default {
|
||||
fetchAvailableDates,
|
||||
fetchDailyAnalysis,
|
||||
fetchWordCloudData,
|
||||
fetchHighPositionStocks,
|
||||
searchStocks,
|
||||
fetchStocksBatchDetail,
|
||||
clearCache,
|
||||
};
|
||||
124
src/types/invoice.ts
Normal file
124
src/types/invoice.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 发票相关类型定义
|
||||
*/
|
||||
|
||||
/** 发票类型 */
|
||||
export type InvoiceType = 'electronic' | 'paper';
|
||||
|
||||
/** 发票抬头类型 */
|
||||
export type InvoiceTitleType = 'personal' | 'company';
|
||||
|
||||
/** 发票状态 */
|
||||
export type InvoiceStatus =
|
||||
| 'pending' // 待处理
|
||||
| 'processing' // 处理中
|
||||
| 'completed' // 已开具
|
||||
| 'rejected' // 已拒绝
|
||||
| 'cancelled'; // 已取消
|
||||
|
||||
/** 发票信息 */
|
||||
export interface InvoiceInfo {
|
||||
id: string;
|
||||
orderId: string; // 关联订单ID
|
||||
orderNo: string; // 订单号
|
||||
userId: number;
|
||||
|
||||
// 发票基本信息
|
||||
invoiceType: InvoiceType; // 电子/纸质
|
||||
titleType: InvoiceTitleType; // 个人/企业
|
||||
title: string; // 发票抬头
|
||||
|
||||
// 企业开票信息
|
||||
taxNumber?: string; // 税号(企业必填)
|
||||
companyAddress?: string; // 公司地址
|
||||
companyPhone?: string; // 公司电话
|
||||
bankName?: string; // 开户银行
|
||||
bankAccount?: string; // 银行账号
|
||||
|
||||
// 发票金额
|
||||
amount: number; // 开票金额
|
||||
|
||||
// 接收信息
|
||||
email: string; // 接收邮箱
|
||||
phone?: string; // 联系电话
|
||||
|
||||
// 纸质发票邮寄信息
|
||||
mailingAddress?: string; // 邮寄地址
|
||||
recipientName?: string; // 收件人姓名
|
||||
recipientPhone?: string; // 收件人电话
|
||||
|
||||
// 状态信息
|
||||
status: InvoiceStatus;
|
||||
invoiceNo?: string; // 发票号码
|
||||
invoiceCode?: string; // 发票代码
|
||||
invoiceUrl?: string; // 电子发票下载链接
|
||||
|
||||
// 时间戳
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string; // 开具完成时间
|
||||
|
||||
// 备注
|
||||
remark?: string; // 用户备注
|
||||
rejectReason?: string; // 拒绝原因
|
||||
}
|
||||
|
||||
/** 创建发票申请请求 */
|
||||
export interface CreateInvoiceRequest {
|
||||
orderId: string;
|
||||
invoiceType: InvoiceType;
|
||||
titleType: InvoiceTitleType;
|
||||
title: string;
|
||||
taxNumber?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
mailingAddress?: string;
|
||||
recipientName?: string;
|
||||
recipientPhone?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** 发票抬头模板(用户保存的常用抬头) */
|
||||
export interface InvoiceTitleTemplate {
|
||||
id: string;
|
||||
userId: number;
|
||||
isDefault: boolean;
|
||||
titleType: InvoiceTitleType;
|
||||
title: string;
|
||||
taxNumber?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
bankName?: string;
|
||||
bankAccount?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 可开票订单 */
|
||||
export interface InvoiceableOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
planName: string; // 套餐名称
|
||||
billingCycle: string; // 计费周期
|
||||
amount: number; // 订单金额
|
||||
paidAt: string; // 支付时间
|
||||
invoiceApplied: boolean; // 是否已申请开票
|
||||
}
|
||||
|
||||
/** 发票列表查询参数 */
|
||||
export interface InvoiceListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: InvoiceStatus;
|
||||
}
|
||||
|
||||
/** 发票统计信息 */
|
||||
export interface InvoiceStats {
|
||||
total: number; // 总申请数
|
||||
pending: number; // 待处理
|
||||
processing: number; // 处理中
|
||||
completed: number; // 已完成
|
||||
}
|
||||
181
src/utils/tradingTimeUtils.js
Normal file
181
src/utils/tradingTimeUtils.js
Normal file
@@ -0,0 +1,181 @@
|
||||
// src/utils/tradingTimeUtils.js
|
||||
// 交易时间相关工具函数
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
|
||||
// 扩展 Day.js 插件
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
/**
|
||||
* 获取当前时间应该显示的实时要闻时间范围
|
||||
* 规则:
|
||||
* - 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
* - 15:30 之后:显示今日 15:00 - 当前时间
|
||||
*
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getCurrentTradingTimeRange = () => {
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
// 计算当前是第几分钟(方便比较)
|
||||
const currentTimeInMinutes = currentHour * 60 + currentMinute;
|
||||
const cutoffTime1500 = 15 * 60; // 15:00 = 900分钟
|
||||
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
|
||||
|
||||
let startTime, endTime, description;
|
||||
|
||||
if (currentTimeInMinutes < cutoffTime1500) {
|
||||
// 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示今日 15:00 - 当前时间
|
||||
startTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = now.toDate();
|
||||
description = '今日15:00 - 当前时间';
|
||||
} else {
|
||||
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
rangeType: currentTimeInMinutes >= cutoffTime1530 ? 'current_day' : 'full_day'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取市场复盘的时间范围
|
||||
* 规则:显示最近一个完整的交易日(昨日 15:00 - 今日 15:00)
|
||||
*
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getMarketReviewTimeRange = () => {
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
// 计算当前是第几分钟
|
||||
const currentTimeInMinutes = currentHour * 60 + currentMinute;
|
||||
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
|
||||
|
||||
let startTime, endTime, description;
|
||||
|
||||
if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日)
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else {
|
||||
// 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日)
|
||||
startTime = dayjs().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '前日15:00 - 昨日15:00';
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
rangeType: 'market_review'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据时间范围过滤事件列表
|
||||
*
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {Date} startTime - 开始时间
|
||||
* @param {Date} endTime - 结束时间
|
||||
* @returns {Array} 过滤后的事件列表
|
||||
*/
|
||||
export const filterEventsByTimeRange = (events, startTime, endTime) => {
|
||||
if (!events || !Array.isArray(events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
return events;
|
||||
}
|
||||
|
||||
const startMoment = dayjs(startTime);
|
||||
const endMoment = dayjs(endTime);
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventTime = dayjs(event.created_at);
|
||||
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断当前是否应该显示市场复盘模块
|
||||
* 根据需求:市场复盘模块一直显示
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldShowMarketReview = () => {
|
||||
// 市场复盘模块始终显示
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取时间范围的描述文本
|
||||
*
|
||||
* @param {Date} startTime - 开始时间
|
||||
* @param {Date} endTime - 结束时间
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startStr = dayjs(startTime).format('MM-DD HH:mm');
|
||||
const endStr = dayjs(endTime).format('MM-DD HH:mm');
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否为交易日(简化版本,只判断周末)
|
||||
* 注意:这里没有考虑节假日,如需精确判断需要接入交易日历API
|
||||
*
|
||||
* @param {Date} date - 日期
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTradingDay = (date) => {
|
||||
const day = dayjs(date).day();
|
||||
// 0 = 周日, 6 = 周六
|
||||
return day !== 0 && day !== 6;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取上一个交易日(简化版本)
|
||||
*
|
||||
* @param {Date} date - 日期
|
||||
* @returns {Date}
|
||||
*/
|
||||
export const getPreviousTradingDay = (date) => {
|
||||
let prevDay = dayjs(date).subtract(1, 'day');
|
||||
|
||||
// 如果是周末,继续往前找
|
||||
while (!isTradingDay(prevDay.toDate())) {
|
||||
prevDay = prevDay.subtract(1, 'day');
|
||||
}
|
||||
|
||||
return prevDay.toDate();
|
||||
};
|
||||
91
src/views/Company/hooks/useCompanyStock.js
Normal file
91
src/views/Company/hooks/useCompanyStock.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// src/views/Company/hooks/useCompanyStock.js
|
||||
// 股票代码管理 Hook - 处理 URL 参数同步和搜索逻辑
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { DEFAULT_STOCK_CODE, URL_PARAM_NAME } from '../constants';
|
||||
|
||||
/**
|
||||
* 股票代码管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 管理当前股票代码状态
|
||||
* - 双向同步 URL 参数
|
||||
* - 处理搜索输入和提交
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} [options.defaultCode] - 默认股票代码
|
||||
* @param {string} [options.paramName] - URL 参数名
|
||||
* @param {Function} [options.onStockChange] - 股票代码变化回调 (newCode, prevCode) => void
|
||||
* @returns {Object} 股票代码状态和操作方法
|
||||
*/
|
||||
export const useCompanyStock = (options = {}) => {
|
||||
const {
|
||||
defaultCode = DEFAULT_STOCK_CODE,
|
||||
paramName = URL_PARAM_NAME,
|
||||
onStockChange,
|
||||
} = options;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 从 URL 参数初始化股票代码
|
||||
const [stockCode, setStockCode] = useState(
|
||||
searchParams.get(paramName) || defaultCode
|
||||
);
|
||||
|
||||
// 输入框状态(默认为空,不显示默认股票代码)
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
|
||||
/**
|
||||
* 监听 URL 参数变化,同步到本地状态
|
||||
* 支持浏览器前进/后退按钮
|
||||
*/
|
||||
useEffect(() => {
|
||||
const urlCode = searchParams.get(paramName);
|
||||
if (urlCode && urlCode !== stockCode) {
|
||||
setStockCode(urlCode);
|
||||
setInputCode(urlCode);
|
||||
}
|
||||
}, [searchParams, paramName, stockCode]);
|
||||
|
||||
/**
|
||||
* 执行搜索 - 更新 stockCode 和 URL
|
||||
* @param {string} [code] - 可选,直接传入股票代码(用于下拉选择)
|
||||
*/
|
||||
const handleSearch = useCallback((code) => {
|
||||
const trimmedCode = code || inputCode?.trim();
|
||||
|
||||
if (trimmedCode && trimmedCode !== stockCode) {
|
||||
// 触发变化回调(用于追踪)
|
||||
onStockChange?.(trimmedCode, stockCode);
|
||||
|
||||
// 更新状态
|
||||
setStockCode(trimmedCode);
|
||||
|
||||
// 更新 URL 参数
|
||||
setSearchParams({ [paramName]: trimmedCode });
|
||||
}
|
||||
}, [inputCode, stockCode, paramName, setSearchParams, onStockChange]);
|
||||
|
||||
/**
|
||||
* 处理键盘事件 - 回车键触发搜索
|
||||
*/
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
stockCode, // 当前确认的股票代码
|
||||
inputCode, // 输入框中的值(未确认)
|
||||
|
||||
// 操作方法
|
||||
setInputCode, // 更新输入框
|
||||
handleSearch, // 执行搜索
|
||||
handleKeyDown, // 处理回车键(改用 onKeyDown)
|
||||
};
|
||||
};
|
||||
|
||||
export default useCompanyStock;
|
||||
166
src/views/Company/hooks/useCompanyWatchlist.js
Normal file
166
src/views/Company/hooks/useCompanyWatchlist.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// src/views/Company/hooks/useCompanyWatchlist.js
|
||||
// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice
|
||||
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
import { logger } from '@utils/logger';
|
||||
import {
|
||||
loadWatchlist,
|
||||
toggleWatchlist,
|
||||
optimisticAddWatchlist,
|
||||
optimisticRemoveWatchlist
|
||||
} from '@store/slices/stockSlice';
|
||||
import { TOAST_MESSAGES } from '../constants';
|
||||
|
||||
/**
|
||||
* Company 页面自选股管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 检查当前股票是否在自选股中
|
||||
* - 提供添加/移除自选股功能
|
||||
* - 与 Redux stockSlice 同步
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.stockCode - 当前股票代码
|
||||
* @param {Object} [options.tracking] - 追踪回调
|
||||
* @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调
|
||||
* @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调
|
||||
* @returns {Object} 自选股状态和操作方法
|
||||
*/
|
||||
export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// 从 Redux 获取自选股列表
|
||||
const watchlist = useSelector((state) => state.stock.watchlist);
|
||||
const watchlistLoading = useSelector((state) => state.stock.loading.watchlist);
|
||||
|
||||
// 追踪是否已初始化(防止无限循环)
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 派生状态:判断当前股票是否在自选股中
|
||||
* 使用 useMemo 避免重复计算
|
||||
*/
|
||||
const isInWatchlist = useMemo(() => {
|
||||
if (!stockCode || !Array.isArray(watchlist)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 标准化股票代码(提取6位数字)
|
||||
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||
const targetCode = normalize(stockCode);
|
||||
|
||||
return watchlist.some((item) => normalize(item.stock_code) === targetCode);
|
||||
}, [watchlist, stockCode]);
|
||||
|
||||
/**
|
||||
* 初始化:加载自选股列表
|
||||
* 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) {
|
||||
hasInitializedRef.current = true;
|
||||
dispatch(loadWatchlist());
|
||||
}
|
||||
}, [isAuthenticated, watchlistLoading, dispatch]);
|
||||
|
||||
/**
|
||||
* 切换自选股状态(乐观更新模式)
|
||||
* 1. 立即更新 UI(无 loading)
|
||||
* 2. 后台静默请求 API
|
||||
* 3. 失败时回滚并提示
|
||||
*/
|
||||
const toggle = useCallback(async () => {
|
||||
// 参数校验
|
||||
if (!stockCode) {
|
||||
logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode });
|
||||
toast(TOAST_MESSAGES.INVALID_CODE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
if (!isAuthenticated) {
|
||||
logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode });
|
||||
toast(TOAST_MESSAGES.LOGIN_REQUIRED);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标准化股票代码用于匹配
|
||||
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||
const targetCode = normalize(stockCode);
|
||||
|
||||
// 从 watchlist 中找到原始 stock_code(保持与后端数据结构一致)
|
||||
const matchedItem = watchlist.find(
|
||||
item => normalize(item.stock_code) === targetCode
|
||||
);
|
||||
// 移除时使用原始 stock_code,添加时使用传入的 stockCode
|
||||
const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode;
|
||||
|
||||
// 保存当前状态用于回滚
|
||||
const wasInWatchlist = isInWatchlist;
|
||||
|
||||
logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', {
|
||||
stockCode,
|
||||
codeForApi,
|
||||
wasInWatchlist,
|
||||
action: wasInWatchlist ? 'remove' : 'add',
|
||||
});
|
||||
|
||||
// 1. 乐观更新:立即更新 UI(不显示 loading)
|
||||
if (wasInWatchlist) {
|
||||
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||
} else {
|
||||
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 后台静默请求 API
|
||||
await dispatch(
|
||||
toggleWatchlist({
|
||||
stockCode: codeForApi,
|
||||
stockName: matchedItem?.stock_name || '',
|
||||
isInWatchlist: wasInWatchlist,
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
// 3. 成功:触发追踪回调(不显示 toast,状态已更新)
|
||||
if (wasInWatchlist) {
|
||||
tracking.onRemove?.(stockCode);
|
||||
} else {
|
||||
tracking.onAdd?.(stockCode);
|
||||
}
|
||||
} catch (error) {
|
||||
// 4. 失败:回滚状态 + 显示错误提示
|
||||
logger.error('useCompanyWatchlist', 'toggle', error, {
|
||||
stockCode,
|
||||
wasInWatchlist,
|
||||
});
|
||||
|
||||
// 回滚操作
|
||||
if (wasInWatchlist) {
|
||||
// 之前在自选中,乐观删除了,现在要恢复
|
||||
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||
} else {
|
||||
// 之前不在自选中,乐观添加了,现在要移除
|
||||
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||
}
|
||||
|
||||
toast(TOAST_MESSAGES.WATCHLIST_ERROR);
|
||||
}
|
||||
}, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isInWatchlist, // 是否在自选股中
|
||||
isLoading: watchlistLoading, // 仅初始加载时显示 loading(乐观更新模式)
|
||||
|
||||
// 操作方法
|
||||
toggle, // 切换自选状态
|
||||
};
|
||||
};
|
||||
|
||||
export default useCompanyWatchlist;
|
||||
29
src/views/Landing/index.js
Normal file
29
src/views/Landing/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/views/Landing/index.js
|
||||
// Landing 页面 - 使用 iframe 嵌入静态 landing.html
|
||||
|
||||
/**
|
||||
* Landing 组件
|
||||
*
|
||||
* 使用 iframe 全屏嵌入 landing.html,保持静态页面的完整功能
|
||||
* 同时可以通过 React 路由访问
|
||||
*/
|
||||
export default function Landing() {
|
||||
return (
|
||||
<iframe
|
||||
src="/landing.html"
|
||||
title="价值前沿 - 金融AI舆情分析系统"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,6 @@ import IconBox from 'components/Icons/IconBox';
|
||||
import { MastercardIcon, VisaIcon } from 'components/Icons/Icons';
|
||||
import { HSeparator } from 'components/Separator/Separator';
|
||||
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';
|
||||
@@ -49,7 +48,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
billingData,
|
||||
invoicesData,
|
||||
newestTransactions,
|
||||
olderTransactions,
|
||||
} from 'variables/general';
|
||||
@@ -70,7 +68,7 @@ function Billing() {
|
||||
templateColumns={{
|
||||
sm: '1fr',
|
||||
md: '1fr 1fr',
|
||||
xl: '1fr 1fr 1fr 1fr',
|
||||
xl: '1fr 1fr 1fr 1fr 1fr',
|
||||
}}
|
||||
templateRows={{ sm: 'auto auto auto', md: '1fr auto', xl: '1fr' }}
|
||||
gap='26px'
|
||||
@@ -97,7 +95,7 @@ function Billing() {
|
||||
>
|
||||
<Flex justify='space-between' align='center'>
|
||||
<Text fontSize='md' fontWeight='bold'>
|
||||
Argon x Chakra
|
||||
价值前沿
|
||||
</Text>
|
||||
<Icon
|
||||
as={CreditCard}
|
||||
@@ -110,20 +108,20 @@ function Billing() {
|
||||
<Flex direction='column'>
|
||||
<Box>
|
||||
<Text fontSize='2xl' letterSpacing='2px' fontWeight='bold'>
|
||||
7812 2139 0823 XXXX
|
||||
**** **** **** 1234
|
||||
</Text>
|
||||
</Box>
|
||||
<Flex mt='14px'>
|
||||
<Flex direction='column' me='34px'>
|
||||
<Text fontSize='xs'>VALID THRU</Text>
|
||||
<Text fontSize='xs'>有效期</Text>
|
||||
<Text fontSize='xs' fontWeight='bold'>
|
||||
05/24
|
||||
12/26
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex direction='column'>
|
||||
<Text fontSize='xs'>CVV</Text>
|
||||
<Text fontSize='xs' fontWeight='bold'>
|
||||
09X
|
||||
***
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -144,7 +142,7 @@ function Billing() {
|
||||
w='100%'
|
||||
>
|
||||
<Text fontSize='md' color={textColor} fontWeight='bold'>
|
||||
Salary
|
||||
账户余额
|
||||
</Text>
|
||||
<Text
|
||||
mb='24px'
|
||||
@@ -152,12 +150,12 @@ function Billing() {
|
||||
color='gray.400'
|
||||
fontWeight='semibold'
|
||||
>
|
||||
Belong Interactive
|
||||
可用于支付
|
||||
</Text>
|
||||
<HSeparator />
|
||||
</Flex>
|
||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
||||
+$2000
|
||||
¥0.00
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
@@ -212,10 +210,10 @@ function Billing() {
|
||||
w='100%'
|
||||
>
|
||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
||||
Payment Method
|
||||
支付方式
|
||||
</Text>
|
||||
<Button variant={colorMode === 'dark' ? 'primary' : 'dark'}>
|
||||
ADD A NEW CARD
|
||||
添加新卡
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
@@ -292,7 +290,7 @@ function Billing() {
|
||||
<CardHeader>
|
||||
<Flex justify='space-between' align='center' mb='1rem' w='100%'>
|
||||
<Text fontSize='lg' color={textColor} fontWeight='bold'>
|
||||
Invoices
|
||||
发票记录
|
||||
</Text>
|
||||
<Button
|
||||
variant='outlined'
|
||||
@@ -301,26 +299,25 @@ function Billing() {
|
||||
_hover={colorMode === 'dark' && 'none'}
|
||||
minW='110px'
|
||||
maxH='35px'
|
||||
onClick={() => navigate('/home/pages/account/invoice')}
|
||||
>
|
||||
VIEW ALL
|
||||
查看全部
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Flex direction='column' w='100%'>
|
||||
{invoicesData.map((row, index) => {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<InvoicesRow
|
||||
date={row.date}
|
||||
code={row.code}
|
||||
price={row.price}
|
||||
logo={row.logo}
|
||||
format={row.format}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<Flex direction='column' w='100%' align='center' py='20px'>
|
||||
<Icon as={FileText} w='40px' h='40px' color='gray.300' mb='10px' />
|
||||
<Text color='gray.400' fontSize='sm' mb='15px'>
|
||||
暂无发票记录
|
||||
</Text>
|
||||
<Button
|
||||
size='sm'
|
||||
colorScheme='blue'
|
||||
onClick={() => navigate('/home/pages/account/invoice')}
|
||||
>
|
||||
申请开票
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Grid>
|
||||
@@ -329,7 +326,7 @@ function Billing() {
|
||||
<Flex direction='column'>
|
||||
<CardHeader py='12px'>
|
||||
<Text color={textColor} fontSize='lg' fontWeight='bold'>
|
||||
Billing Information
|
||||
账单信息
|
||||
</Text>
|
||||
</CardHeader>
|
||||
|
||||
@@ -364,7 +361,7 @@ function Billing() {
|
||||
fontSize={{ sm: 'lg', md: 'xl', lg: 'lg' }}
|
||||
fontWeight='bold'
|
||||
>
|
||||
Your Transactions
|
||||
交易记录
|
||||
</Text>
|
||||
<Flex align='center'>
|
||||
<Icon
|
||||
@@ -388,7 +385,7 @@ function Billing() {
|
||||
fontWeight='semibold'
|
||||
my='12px'
|
||||
>
|
||||
NEWEST
|
||||
最近
|
||||
</Text>
|
||||
{newestTransactions.map((row, index) => {
|
||||
return (
|
||||
@@ -408,7 +405,7 @@ function Billing() {
|
||||
fontWeight='semibold'
|
||||
my='12px'
|
||||
>
|
||||
OLDER
|
||||
更早
|
||||
</Text>
|
||||
{olderTransactions.map((row, index) => {
|
||||
return (
|
||||
|
||||
358
src/views/Pages/Account/Invoice/index.tsx
Normal file
358
src/views/Pages/Account/Invoice/index.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 发票管理页面
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
SimpleGrid,
|
||||
Spinner,
|
||||
Center,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
useDisclosure,
|
||||
AlertDialog,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
} from '@chakra-ui/react';
|
||||
import { FileText, Plus, RefreshCw, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import Card from '@components/Card/Card';
|
||||
import CardHeader from '@components/Card/CardHeader';
|
||||
import { InvoiceCard, InvoiceApplyModal } from '@components/Invoice';
|
||||
import {
|
||||
getInvoiceList,
|
||||
getInvoiceStats,
|
||||
cancelInvoice,
|
||||
downloadInvoice,
|
||||
} from '@/services/invoiceService';
|
||||
import type { InvoiceInfo, InvoiceStatus, InvoiceStats } from '@/types/invoice';
|
||||
|
||||
type TabType = 'all' | 'pending' | 'processing' | 'completed';
|
||||
|
||||
const tabConfig: { key: TabType; label: string; status?: InvoiceStatus }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待处理', status: 'pending' },
|
||||
{ key: 'processing', label: '处理中', status: 'processing' },
|
||||
{ key: 'completed', label: '已完成', status: 'completed' },
|
||||
];
|
||||
|
||||
export default function InvoicePage() {
|
||||
const [invoices, setInvoices] = useState<InvoiceInfo[]>([]);
|
||||
const [stats, setStats] = useState<InvoiceStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [cancelingId, setCancelingId] = useState<string | null>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const cancelDialogRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const {
|
||||
isOpen: isApplyOpen,
|
||||
onOpen: onApplyOpen,
|
||||
onClose: onApplyClose,
|
||||
} = useDisclosure();
|
||||
|
||||
const {
|
||||
isOpen: isCancelOpen,
|
||||
onOpen: onCancelOpen,
|
||||
onClose: onCancelClose,
|
||||
} = useDisclosure();
|
||||
|
||||
// 加载发票列表
|
||||
const loadInvoices = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const status = tabConfig.find((t) => t.key === activeTab)?.status;
|
||||
const res = await getInvoiceList({ status, pageSize: 50 });
|
||||
if (res.code === 200 && res.data) {
|
||||
setInvoices(res.data.list || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载发票列表失败:', error);
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法获取发票列表',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, toast]);
|
||||
|
||||
// 加载统计信息
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await getInvoiceStats();
|
||||
if (res.code === 200 && res.data) {
|
||||
setStats(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载发票统计失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
}, [loadInvoices, loadStats]);
|
||||
|
||||
// 取消发票申请
|
||||
const handleCancel = async () => {
|
||||
if (!cancelingId) return;
|
||||
|
||||
try {
|
||||
const res = await cancelInvoice(cancelingId);
|
||||
if (res.code === 200) {
|
||||
toast({
|
||||
title: '取消成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
} else {
|
||||
toast({
|
||||
title: '取消失败',
|
||||
description: res.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '取消失败',
|
||||
description: '网络错误',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setCancelingId(null);
|
||||
onCancelClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 下载发票
|
||||
const handleDownload = async (invoice: InvoiceInfo) => {
|
||||
try {
|
||||
const blob = await downloadInvoice(invoice.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `发票_${invoice.invoiceNo || invoice.id}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '下载失败',
|
||||
description: '无法下载发票文件',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 开始取消流程
|
||||
const startCancel = (invoiceId: string) => {
|
||||
setCancelingId(invoiceId);
|
||||
onCancelOpen();
|
||||
};
|
||||
|
||||
// 申请成功回调
|
||||
const handleApplySuccess = () => {
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" pt={{ base: '120px', md: '75px' }}>
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={6}>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">全部申请</StatLabel>
|
||||
<StatNumber color={textColor}>{stats.total}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={FileText} boxSize={3} mr={1} />
|
||||
累计
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">待处理</StatLabel>
|
||||
<StatNumber color="yellow.500">{stats.pending}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={Clock} boxSize={3} mr={1} />
|
||||
等待中
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">处理中</StatLabel>
|
||||
<StatNumber color="blue.500">{stats.processing}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={AlertCircle} boxSize={3} mr={1} />
|
||||
开具中
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
<Card p={4}>
|
||||
<Stat>
|
||||
<StatLabel color="gray.500">已完成</StatLabel>
|
||||
<StatNumber color="green.500">{stats.completed}</StatNumber>
|
||||
<StatHelpText>
|
||||
<Icon as={CheckCircle} boxSize={3} mr={1} />
|
||||
已开具
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center" w="100%" mb={4}>
|
||||
<HStack>
|
||||
<Icon as={FileText} boxSize={6} color="blue.500" />
|
||||
<Text fontSize="xl" fontWeight="bold" color={textColor}>
|
||||
发票管理
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Icon as={RefreshCw} />}
|
||||
onClick={() => {
|
||||
loadInvoices();
|
||||
loadStats();
|
||||
}}
|
||||
isLoading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<Icon as={Plus} />}
|
||||
onClick={onApplyOpen}
|
||||
>
|
||||
申请开票
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Tabs
|
||||
index={tabConfig.findIndex((t) => t.key === activeTab)}
|
||||
onChange={(index) => setActiveTab(tabConfig[index].key)}
|
||||
>
|
||||
<TabList>
|
||||
{tabConfig.map((tab) => (
|
||||
<Tab key={tab.key}>{tab.label}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{tabConfig.map((tab) => (
|
||||
<TabPanel key={tab.key} px={0}>
|
||||
{loading ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text color="gray.500">加载中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : invoices.length === 0 ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Icon as={FileText} boxSize={12} color="gray.300" />
|
||||
<Text color="gray.500">暂无发票记录</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<Icon as={Plus} />}
|
||||
onClick={onApplyOpen}
|
||||
>
|
||||
申请开票
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{invoices.map((invoice) => (
|
||||
<InvoiceCard
|
||||
key={invoice.id}
|
||||
invoice={invoice}
|
||||
onDownload={() => handleDownload(invoice)}
|
||||
onCancel={() => startCancel(invoice.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* 申请开票弹窗 */}
|
||||
<InvoiceApplyModal
|
||||
isOpen={isApplyOpen}
|
||||
onClose={onApplyClose}
|
||||
onSuccess={handleApplySuccess}
|
||||
/>
|
||||
|
||||
{/* 取消确认对话框 */}
|
||||
<AlertDialog
|
||||
isOpen={isCancelOpen}
|
||||
leastDestructiveRef={cancelDialogRef}
|
||||
onClose={onCancelClose}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
取消开票申请
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>确定要取消这个开票申请吗?取消后可重新申请。</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelDialogRef} onClick={onCancelClose}>
|
||||
返回
|
||||
</Button>
|
||||
<Button colorScheme="red" onClick={handleCancel} ml={3}>
|
||||
确认取消
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Image,
|
||||
Stack,
|
||||
Table,
|
||||
Tbody,
|
||||
@@ -77,10 +76,10 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="lg"
|
||||
mb="12px"
|
||||
>
|
||||
St. Independence Embankment, 050105 Bucharest, Romania
|
||||
北京市海淀区中关村大街1号
|
||||
</Text>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
tel: +4 (074) 1090873
|
||||
电话: 010-12345678
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex
|
||||
@@ -94,10 +93,10 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="lg"
|
||||
mb="12px"
|
||||
>
|
||||
Billed to: John Doe
|
||||
购买方: 张三
|
||||
</Text>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
4006 Locust View Drive San Francisco CA California
|
||||
上海市浦东新区陆家嘴金融中心
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -118,7 +117,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="md"
|
||||
mb="8px"
|
||||
>
|
||||
Invoice no
|
||||
发票号码
|
||||
</Text>
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="lg">
|
||||
#0453119
|
||||
@@ -127,18 +126,18 @@ class ComponentToPrint extends React.Component {
|
||||
<Flex direction="column">
|
||||
<Stack direction="row" mb="8px" justify={{ md: "end" }}>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
Invoice date:{" "}
|
||||
开票日期:{" "}
|
||||
</Text>
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="lg">
|
||||
06/03/2022
|
||||
2024/03/06
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" justify={{ md: "end" }}>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
Due date:{" "}
|
||||
有效期至:{" "}
|
||||
</Text>
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="lg">
|
||||
29/07/2022
|
||||
2024/07/29
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
@@ -154,7 +153,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
ps="0px"
|
||||
>
|
||||
Item
|
||||
项目
|
||||
</Th>
|
||||
<Th
|
||||
borderColor={borderColor}
|
||||
@@ -162,7 +161,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
>
|
||||
Quantity
|
||||
数量
|
||||
</Th>
|
||||
<Th
|
||||
borderColor={borderColor}
|
||||
@@ -170,7 +169,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
>
|
||||
Rate
|
||||
单价
|
||||
</Th>
|
||||
<Th
|
||||
borderColor={borderColor}
|
||||
@@ -178,7 +177,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
>
|
||||
Amount
|
||||
金额
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
@@ -194,7 +193,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
Premium Support
|
||||
Pro 专业版会员服务
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
@@ -216,7 +215,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 9.00
|
||||
¥ 2699.00
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
@@ -225,49 +224,7 @@ class ComponentToPrint extends React.Component {
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 9.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td
|
||||
borderColor={borderColor}
|
||||
ps="0px"
|
||||
minW={{ sm: "300px" }}
|
||||
>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
Chakra UI - Dashboard PRO
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
3
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 99.00
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 297.00
|
||||
¥ 2699.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -278,40 +235,12 @@ class ComponentToPrint extends React.Component {
|
||||
minW={{ sm: "300px" }}
|
||||
border="none"
|
||||
>
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
Parts for Service
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor} border="none">
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
1
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor} border="none">
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 89.00
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor} border="none">
|
||||
<Text
|
||||
color={secondaryColor}
|
||||
fontWeight="normal"
|
||||
fontSize="md"
|
||||
>
|
||||
$ 89.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
@@ -323,12 +252,12 @@ class ComponentToPrint extends React.Component {
|
||||
<Td borderColor={borderColor}></Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text color={textColor} fontWeight="bold" fontSize="xl">
|
||||
Total
|
||||
合计
|
||||
</Text>
|
||||
</Td>
|
||||
<Td borderColor={borderColor}>
|
||||
<Text color={textColor} fontWeight="bold" fontSize="xl">
|
||||
$ 9.00
|
||||
¥ 2699.00
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -341,7 +270,7 @@ class ComponentToPrint extends React.Component {
|
||||
>
|
||||
<Flex direction="column" maxW="270px">
|
||||
<Text color={secondaryColor} fontWeight="bold" fontSize="xl">
|
||||
Thank You!
|
||||
感谢您的支持!
|
||||
</Text>
|
||||
<Text
|
||||
color="gray.400"
|
||||
@@ -350,13 +279,12 @@ class ComponentToPrint extends React.Component {
|
||||
mt="6px"
|
||||
mb="30px"
|
||||
>
|
||||
If you encounter any issues related to the invoice you can
|
||||
contact us at:
|
||||
如果您对发票有任何问题,请联系我们:
|
||||
</Text>
|
||||
<Text color="gray.400" fontWeight="normal" fontSize="md">
|
||||
email:{" "}
|
||||
邮箱:{" "}
|
||||
<Text as="span" color={secondaryColor} fontWeight="bold">
|
||||
support@creative-tim.com
|
||||
support@valuefrontier.cn
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
@@ -368,7 +296,7 @@ class ComponentToPrint extends React.Component {
|
||||
alignSelf={{ sm: "flex-start", md: "flex-end" }}
|
||||
mt={{ sm: "16px", md: "0px" }}
|
||||
>
|
||||
PRINT
|
||||
打印
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -391,7 +319,12 @@ function Invoice() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex direction="column" pt={{ sm: "100px", lg: "50px" }}>
|
||||
<Flex
|
||||
direction="column"
|
||||
minH="100vh"
|
||||
justify="center"
|
||||
align="center"
|
||||
>
|
||||
<ComponentToPrint
|
||||
ref={componentRef}
|
||||
handlePrint={handlePrint}
|
||||
35
src/views/Pricing/content.tsx
Normal file
35
src/views/Pricing/content.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
export const pricing = [
|
||||
{
|
||||
title: "STARTER",
|
||||
price: 99,
|
||||
features: [
|
||||
"1 Active Bot",
|
||||
"1,000 Conversations per month",
|
||||
"Web & WhatsApp Integration",
|
||||
"Basic Dashboard & Chat Reports",
|
||||
"Email Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "PRO",
|
||||
price: 149,
|
||||
features: [
|
||||
"Up to 5 Active Bots",
|
||||
"10,000 Conversations per month",
|
||||
"Multi-Channel (Web, WhatsApp, IG, Telegram)",
|
||||
"Custom Workflows & Automation",
|
||||
"Real-Time Reports & Zapier Integration",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "ENTERPRISE",
|
||||
price: 199,
|
||||
features: [
|
||||
"Unlimited Bots & Chats",
|
||||
"Role-Based Access & Team Management",
|
||||
"Integration to CRM & Custom APIs",
|
||||
"Advanced AI Training (LLM/NLP)",
|
||||
"Dedicated Onboarding Team",
|
||||
],
|
||||
},
|
||||
];
|
||||
129
src/views/Pricing/index.tsx
Normal file
129
src/views/Pricing/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
import { pricing } from "./content";
|
||||
|
||||
const Pricing = () => (
|
||||
<div
|
||||
id="pricing"
|
||||
className="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15"
|
||||
>
|
||||
<div className="center">
|
||||
<motion.div
|
||||
className="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.7 }}
|
||||
viewport={{ amount: 0.7 }}
|
||||
>
|
||||
<div className="label mb-3 max-md:mb-1.5">Pricing</div>
|
||||
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">
|
||||
Start Automation Today
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex gap-4 max-lg:-mx-10 max-lg:px-10 max-lg:overflow-x-auto max-lg:scrollbar-none max-md:-mx-5 max-md:px-5"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.7 }}
|
||||
viewport={{ amount: 0.35 }}
|
||||
>
|
||||
{pricing.map((item, index) => (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 rounded-[1.25rem] overflow-hidden after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84 ${
|
||||
item.title === "PRO"
|
||||
? "shadow-2 before:absolute before:-top-20 before:left-1/2 before:z-1 before:-translate-x-1/2 before:w-65 before:h-57 before:bg-green/10 before:rounded-full before:blur-[3.375rem]"
|
||||
: "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset]"
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
{item.title === "PRO" && (
|
||||
<div className="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
|
||||
<video
|
||||
className="w-full"
|
||||
src="/videos/video-1.mp4"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 ${
|
||||
item.title === "PRO"
|
||||
? "bg-[#175673]/20 rounded-t-[1.25rem] text-green"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none ${
|
||||
item.title === "PRO"
|
||||
? "backdrop-blur-[2rem] shadow-2 bg-white/7"
|
||||
: "backdrop-blur-[1.25rem] bg-white/1"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative mb-8 p-5 rounded-[0.8125rem] backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none ${
|
||||
item.title === "PRO"
|
||||
? "bg-line"
|
||||
: "bg-white/2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-end gap-3 mb-4">
|
||||
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">
|
||||
${item.price}
|
||||
</div>
|
||||
<div className="text-title-5">/Month</div>
|
||||
</div>
|
||||
<Button
|
||||
className={`w-full bg-line ${
|
||||
item.title !== "PRO"
|
||||
? "!text-description hover:!text-white"
|
||||
: ""
|
||||
}`}
|
||||
isPrimary={item.title === "PRO"}
|
||||
isSecondary={item.title !== "PRO"}
|
||||
>
|
||||
{item.title === "STARTER"
|
||||
? "Start with Beginner"
|
||||
: item.title === "PRO"
|
||||
? "Choose Pro Plan"
|
||||
: "Contact for Enterprise"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
|
||||
{item.features.map((feature, index) => (
|
||||
<div
|
||||
className="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile"
|
||||
key={index}
|
||||
>
|
||||
<div className="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg
|
||||
className="size-5 fill-black"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="mt-13.5 text-center max-md:mt-8 max-md:text-title-3-mobile">
|
||||
Free 7 Day Trial
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
@@ -37,10 +37,12 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useProfileEvents } from '../../hooks/useProfileEvents';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 深色模式固定颜色(Settings 页面始终使用深色主题)
|
||||
const headingColor = 'white';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
||||
export { default as ConceptAlertList } from './ConceptAlertList';
|
||||
export { default as AlertSummary } from './AlertSummary';
|
||||
export { default as AlertDetailDrawer } from './AlertDetailDrawer';
|
||||
@@ -0,0 +1 @@
|
||||
export { useHotspotData } from './useHotspotData';
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 热点概览数据获取 Hook
|
||||
* 负责获取指数分时数据和概念异动数据
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
/**
|
||||
* @param {Date|null} selectedDate - 选中的交易日期
|
||||
* @returns {Object} 数据和状态
|
||||
*/
|
||||
export const useHotspotData = (selectedDate) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
// 用于防止 React StrictMode 下的双重请求
|
||||
const fetchingRef = useRef(false);
|
||||
const lastDateRef = useRef(null);
|
||||
|
||||
const fetchData = useCallback(async (forceRefetch = false) => {
|
||||
// 获取日期字符串用于比较
|
||||
const dateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : 'today';
|
||||
|
||||
// 如果正在请求中,跳过(除非是强制刷新)
|
||||
if (fetchingRef.current && !forceRefetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果日期未变化且已有数据,跳过(除非是强制刷新)
|
||||
if (lastDateRef.current === dateStr && !forceRefetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const dateParam = selectedDate
|
||||
? `?date=${dateStr}`
|
||||
: '';
|
||||
const response = await fetch(`${getApiBase()}/api/market/hotspot-overview${dateParam}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
lastDateRef.current = dateStr;
|
||||
} else {
|
||||
setError(result.error || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useHotspotData', 'fetchData', err);
|
||||
setError('网络请求失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
fetchingRef.current = false;
|
||||
}
|
||||
}, [selectedDate]); // 移除 data 依赖,避免循环更新
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
refetch: () => fetchData(true),
|
||||
};
|
||||
};
|
||||
|
||||
export default useHotspotData;
|
||||
761
src/views/StockOverview/components/HotspotOverview/index.js
Normal file
761
src/views/StockOverview/components/HotspotOverview/index.js
Normal file
@@ -0,0 +1,761 @@
|
||||
/**
|
||||
* 热点概览组件 - Modern Spatial & Glassmorphism 设计
|
||||
* 展示大盘分时走势 + 概念异动标注
|
||||
*
|
||||
* 布局设计:
|
||||
* - 顶部:统计摘要(指数信息 + 异动统计)
|
||||
* - 中部:大尺寸分时图(主要展示区域)
|
||||
* - 底部:异动列表(横向滚动卡片)
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Flex,
|
||||
Spacer,
|
||||
Tooltip,
|
||||
SimpleGrid,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { keyframes, css } from '@emotion/react';
|
||||
import {
|
||||
Flame,
|
||||
List,
|
||||
LineChart,
|
||||
Info,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useHotspotData } from './hooks';
|
||||
import { IndexMinuteChart, AlertDetailDrawer } from './components';
|
||||
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
||||
import {
|
||||
glassEffect,
|
||||
colors,
|
||||
glowEffects,
|
||||
getMarketColor,
|
||||
getMarketGlow,
|
||||
} from '../../theme/glassTheme';
|
||||
|
||||
// 动画效果
|
||||
const gradientShift = keyframes`
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
`;
|
||||
|
||||
const pulseGlow = keyframes`
|
||||
0%, 100% { opacity: 0.6; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.02); }
|
||||
`;
|
||||
|
||||
const floatAnimation = keyframes`
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-3px); }
|
||||
`;
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 紧凑型异动卡片(用于横向滚动)- Glassmorphism 风格
|
||||
*/
|
||||
const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
||||
const config = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
||||
const isUp = alert.alert_type !== 'surge_down';
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={glassEffect.light.bg}
|
||||
backdropFilter={glassEffect.light.backdropFilter}
|
||||
borderRadius="16px"
|
||||
border={isSelected ? `1px solid ${config.color}60` : glassEffect.light.border}
|
||||
p={3}
|
||||
minW="180px"
|
||||
maxW="200px"
|
||||
cursor="pointer"
|
||||
onClick={() => onClick?.(alert)}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
border: `1px solid ${config.color}50`,
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: `0 8px 25px ${config.color}20, inset 0 1px 0 rgba(255,255,255,0.1)`,
|
||||
}}
|
||||
css={isSelected ? css`animation: ${floatAnimation} 3s ease-in-out infinite;` : undefined}
|
||||
>
|
||||
{/* 顶部渐变发光条 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
h="2px"
|
||||
bgGradient={`linear(to-r, ${config.gradient[0]}, ${config.gradient[1]})`}
|
||||
opacity={isSelected ? 1 : 0.7}
|
||||
boxShadow={isSelected ? `0 0 15px ${config.color}60` : 'none'}
|
||||
/>
|
||||
|
||||
{/* 背景光晕 */}
|
||||
{isSelected && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-30px"
|
||||
right="-30px"
|
||||
w="80px"
|
||||
h="80px"
|
||||
borderRadius="full"
|
||||
bg={`${config.color}15`}
|
||||
filter="blur(25px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 时间 + 类型 */}
|
||||
<HStack justify="space-between" mb={1.5}>
|
||||
<Text fontSize="xs" color={colors.text.muted} fontFamily="mono">
|
||||
{alert.time}
|
||||
</Text>
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
bg={`${config.color}20`}
|
||||
border={`1px solid ${config.color}30`}
|
||||
>
|
||||
<Icon
|
||||
as={isUp ? TrendingUp : TrendingDown}
|
||||
boxSize={3}
|
||||
color={config.color}
|
||||
css={css`filter: drop-shadow(0 0 4px ${config.color}80);`}
|
||||
/>
|
||||
<Text fontSize="10px" fontWeight="bold" color={config.color}>
|
||||
{getAlertTypeLabel(alert.alert_type)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 概念名称 */}
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
color={colors.text.primary}
|
||||
noOfLines={1}
|
||||
mb={1.5}
|
||||
css={isSelected ? css`text-shadow: 0 0 20px ${config.color}40;` : undefined}
|
||||
>
|
||||
{alert.concept_name}
|
||||
</Text>
|
||||
|
||||
{/* 分数 + Alpha */}
|
||||
<HStack justify="space-between" fontSize="xs">
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.text.tertiary}>评分</Text>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={config.color}
|
||||
css={css`text-shadow: 0 0 10px ${config.color}50;`}
|
||||
>
|
||||
{Math.round(alert.final_score || 0)}
|
||||
</Text>
|
||||
</HStack>
|
||||
{alert.alpha != null && (
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={getMarketColor(alert.alpha)}
|
||||
css={css`text-shadow: 0 0 10px ${getMarketColor(alert.alpha)}50;`}
|
||||
>
|
||||
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(1)}%
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 热点概览主组件
|
||||
* @param {Object} props
|
||||
* @param {Date|null} props.selectedDate - 选中的交易日期
|
||||
*/
|
||||
const HotspotOverview = ({ selectedDate }) => {
|
||||
const [selectedAlert, setSelectedAlert] = useState(null);
|
||||
const [drawerAlertData, setDrawerAlertData] = useState(null);
|
||||
|
||||
// 右边栏抽屉控制
|
||||
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
|
||||
|
||||
// 获取数据
|
||||
const { loading, error, data } = useHotspotData(selectedDate);
|
||||
|
||||
// Glassmorphism 颜色主题
|
||||
const cardBg = glassEffect.card.bg;
|
||||
const borderColor = colors.border.primary;
|
||||
const textColor = colors.text.primary;
|
||||
const subTextColor = colors.text.secondary;
|
||||
const sectionBg = glassEffect.light.bg;
|
||||
const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
|
||||
|
||||
// 点击分时图上的异动标注 - 打开右边栏抽屉显示详情
|
||||
const handleChartAlertClick = useCallback((alertGroupData) => {
|
||||
// alertGroupData 包含 { alerts, timeRange, alertCount, time }
|
||||
setDrawerAlertData(alertGroupData);
|
||||
onDrawerOpen();
|
||||
}, [onDrawerOpen]);
|
||||
|
||||
// 点击底部异动卡片 - 打开右边栏抽屉显示单个异动详情
|
||||
const handleCardAlertClick = useCallback((alert) => {
|
||||
setSelectedAlert(alert);
|
||||
// 构造单个异动的数据格式
|
||||
setDrawerAlertData({
|
||||
alerts: [alert],
|
||||
timeRange: alert.time,
|
||||
alertCount: 1,
|
||||
time: alert.time,
|
||||
});
|
||||
onDrawerOpen();
|
||||
}, [onDrawerOpen]);
|
||||
|
||||
// 渲染加载状态 - Glassmorphism 风格
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
backdropFilter={glassEffect.card.backdropFilter}
|
||||
borderRadius="24px"
|
||||
border={glassEffect.card.border}
|
||||
boxShadow={glassEffect.card.boxShadow}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
{/* 极光背景 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
bgGradient="radial(ellipse at 30% 20%, rgba(139, 92, 246, 0.15) 0%, transparent 50%)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{/* 顶部发光条 */}
|
||||
<Box
|
||||
h="3px"
|
||||
bgGradient="linear(to-r, #8b5cf6, #ec4899, #f97316)"
|
||||
backgroundSize="200% 200%"
|
||||
css={css`animation: ${gradientShift} 3s ease infinite;`}
|
||||
boxShadow="0 0 20px rgba(139, 92, 246, 0.5)"
|
||||
/>
|
||||
<Center h="500px" p={6}>
|
||||
<VStack spacing={6}>
|
||||
<Box position="relative">
|
||||
<Spinner size="xl" color="#8b5cf6" thickness="3px" speed="0.8s" />
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={-4}
|
||||
borderRadius="full"
|
||||
css={css`animation: ${pulseGlow} 2s ease-in-out infinite;`}
|
||||
boxShadow="0 0 40px rgba(139, 92, 246, 0.4)"
|
||||
/>
|
||||
</Box>
|
||||
<VStack spacing={2}>
|
||||
<Text
|
||||
color={textColor}
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.5);`}
|
||||
>
|
||||
加载热点概览数据
|
||||
</Text>
|
||||
<Text color={subTextColor} fontSize="sm">正在获取市场异动信息...</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染错误状态 - Glassmorphism 风格
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
backdropFilter={glassEffect.card.backdropFilter}
|
||||
borderRadius="24px"
|
||||
border="1px solid rgba(239, 68, 68, 0.3)"
|
||||
boxShadow="0 8px 32px rgba(239, 68, 68, 0.1)"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
<Box h="3px" bg="#ef4444" boxShadow="0 0 15px rgba(239, 68, 68, 0.5)" />
|
||||
<Center h="400px" p={6}>
|
||||
<VStack spacing={4}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="full"
|
||||
bg="rgba(239, 68, 68, 0.1)"
|
||||
border="1px solid rgba(239, 68, 68, 0.2)"
|
||||
>
|
||||
<Icon
|
||||
as={AlertCircle}
|
||||
boxSize={10}
|
||||
color="#ef4444"
|
||||
css={css`filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.5));`}
|
||||
/>
|
||||
</Box>
|
||||
<VStack spacing={1}>
|
||||
<Text
|
||||
color="#ef4444"
|
||||
fontWeight="bold"
|
||||
css={css`text-shadow: 0 0 15px rgba(239, 68, 68, 0.5);`}
|
||||
>
|
||||
数据加载失败
|
||||
</Text>
|
||||
<Text color={subTextColor} fontSize="sm" textAlign="center">{error}</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const { index, alerts, alert_summary } = data;
|
||||
|
||||
// 计算市场颜色
|
||||
const marketColor = getMarketColor(index?.change_pct || 0);
|
||||
const marketGlow = getMarketGlow(index?.change_pct || 0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
backdropFilter={glassEffect.card.backdropFilter}
|
||||
borderRadius="24px"
|
||||
border={glassEffect.card.border}
|
||||
boxShadow={glassEffect.card.boxShadow}
|
||||
overflow="hidden"
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
position="relative"
|
||||
>
|
||||
{/* 极光背景装饰 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
bgGradient="radial(ellipse at 20% 10%, rgba(139, 92, 246, 0.12) 0%, transparent 50%)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
bgGradient="radial(ellipse at 80% 90%, rgba(236, 72, 153, 0.08) 0%, transparent 50%)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* 顶部发光装饰条 */}
|
||||
<Box
|
||||
h="3px"
|
||||
bgGradient="linear(to-r, #8b5cf6, #ec4899, #f97316)"
|
||||
backgroundSize="200% 200%"
|
||||
css={css`animation: ${gradientShift} 3s ease infinite;`}
|
||||
boxShadow="0 0 20px rgba(139, 92, 246, 0.5)"
|
||||
/>
|
||||
|
||||
<Box p={6} position="relative">
|
||||
{/* 头部 - Glassmorphism */}
|
||||
<Flex align="center" mb={5}>
|
||||
<HStack spacing={4}>
|
||||
<Box
|
||||
p={3}
|
||||
borderRadius="16px"
|
||||
bgGradient="linear(to-br, #8b5cf6, #ec4899)"
|
||||
boxShadow="0 8px 25px rgba(139, 92, 246, 0.4)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 图标发光效果 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
bgGradient="linear(to-br, rgba(255,255,255,0.2), transparent)"
|
||||
/>
|
||||
<Icon
|
||||
as={Flame}
|
||||
boxSize={6}
|
||||
color="white"
|
||||
css={css`filter: drop-shadow(0 0 8px rgba(255,255,255,0.5));`}
|
||||
/>
|
||||
</Box>
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Heading
|
||||
size="md"
|
||||
color={textColor}
|
||||
fontWeight="bold"
|
||||
css={css`text-shadow: 0 0 30px rgba(139, 92, 246, 0.3);`}
|
||||
>
|
||||
热点概览
|
||||
</Heading>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
|
||||
<Text fontSize="xs" color={subTextColor}>实时概念异动监控</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<HStack spacing={3}>
|
||||
{alerts.length > 0 && (
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
bg="rgba(139, 92, 246, 0.15)"
|
||||
border="1px solid rgba(139, 92, 246, 0.3)"
|
||||
boxShadow="0 0 15px rgba(139, 92, 246, 0.2)"
|
||||
>
|
||||
<Icon
|
||||
as={Zap}
|
||||
boxSize={4}
|
||||
color={colors.accent.purple}
|
||||
css={css`filter: drop-shadow(0 0 6px #8b5cf6);`}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={colors.accent.purple}
|
||||
css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`}
|
||||
>
|
||||
{alerts.length}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<Tooltip label="展示大盘走势与概念异动的关联" hasArrow maxW="200px">
|
||||
<Box cursor="help" p={2} borderRadius="full" _hover={{ bg: 'rgba(255,255,255,0.05)' }}>
|
||||
<Icon as={Info} color={subTextColor} boxSize={4} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 统计摘要 - Glassmorphism Bento Grid */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={5}>
|
||||
{/* 指数信息卡片 */}
|
||||
<Box
|
||||
bg={sectionBg}
|
||||
backdropFilter={glassEffect.light.backdropFilter}
|
||||
borderRadius="20px"
|
||||
border={glassEffect.light.border}
|
||||
p={5}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
border: `1px solid ${marketColor}30`,
|
||||
boxShadow: `0 8px 30px ${marketColor}15`,
|
||||
}}
|
||||
>
|
||||
{/* 背景光晕 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-20px"
|
||||
right="-20px"
|
||||
w="100px"
|
||||
h="100px"
|
||||
borderRadius="full"
|
||||
bg={`${marketColor}10`}
|
||||
filter="blur(30px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="xs" color={colors.text.muted} letterSpacing="1px" textTransform="uppercase">
|
||||
{index?.name || '上证指数'}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="3xl"
|
||||
fontWeight="bold"
|
||||
color={marketColor}
|
||||
css={css`text-shadow: 0 0 30px ${marketColor}60;`}
|
||||
>
|
||||
{index?.latest_price?.toFixed(2) || '-'}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack align="flex-end" spacing={2}>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
bg={`${marketColor}15`}
|
||||
border={`1px solid ${marketColor}25`}
|
||||
>
|
||||
<Icon
|
||||
as={(index?.change_pct || 0) >= 0 ? TrendingUp : TrendingDown}
|
||||
boxSize={4}
|
||||
color={marketColor}
|
||||
css={css`filter: drop-shadow(0 0 4px ${marketColor});`}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={marketColor}
|
||||
css={css`text-shadow: 0 0 10px ${marketColor}50;`}
|
||||
>
|
||||
{(index?.change_pct || 0) >= 0 ? '+' : ''}{(index?.change_pct || 0).toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={4} fontSize="xs" color={colors.text.tertiary}>
|
||||
<HStack spacing={1}>
|
||||
<Text>高</Text>
|
||||
<Text color={colors.market.up} fontWeight="bold">{index?.high?.toFixed(2)}</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Text>低</Text>
|
||||
<Text color={colors.market.down} fontWeight="bold">{index?.low?.toFixed(2)}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 异动统计卡片 */}
|
||||
<Box
|
||||
bg={sectionBg}
|
||||
backdropFilter={glassEffect.light.backdropFilter}
|
||||
borderRadius="20px"
|
||||
border={glassEffect.light.border}
|
||||
p={5}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||
boxShadow: '0 8px 30px rgba(139, 92, 246, 0.1)',
|
||||
}}
|
||||
>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>今日异动</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={colors.accent.purple}
|
||||
fontWeight="bold"
|
||||
css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`}
|
||||
>
|
||||
{alerts.length} 次
|
||||
</Text>
|
||||
</HStack>
|
||||
<Flex gap={2} flexWrap="wrap">
|
||||
{Object.entries(alert_summary || {})
|
||||
.filter(([_, count]) => count > 0)
|
||||
.slice(0, 5)
|
||||
.map(([type, count]) => {
|
||||
const config = ALERT_TYPE_CONFIG[type];
|
||||
if (!config) return null;
|
||||
return (
|
||||
<HStack
|
||||
key={type}
|
||||
spacing={1.5}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
bg={`${config.color}15`}
|
||||
border={`1px solid ${config.color}25`}
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: `${config.color}25`,
|
||||
boxShadow: `0 0 15px ${config.color}30`,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="xs" color={config.color}>{config.label}</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={config.color}
|
||||
css={css`text-shadow: 0 0 8px ${config.color}50;`}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 大尺寸分时图 - Glassmorphism */}
|
||||
<Box
|
||||
bg={sectionBg}
|
||||
backdropFilter={glassEffect.light.backdropFilter}
|
||||
borderRadius="20px"
|
||||
border={glassEffect.light.border}
|
||||
p={5}
|
||||
mb={5}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 图表区域背景光晕 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-50px"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
w="60%"
|
||||
h="100px"
|
||||
borderRadius="full"
|
||||
bg="rgba(139, 92, 246, 0.08)"
|
||||
filter="blur(40px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="12px"
|
||||
bg="rgba(139, 92, 246, 0.15)"
|
||||
border="1px solid rgba(139, 92, 246, 0.25)"
|
||||
>
|
||||
<Icon
|
||||
as={LineChart}
|
||||
boxSize={5}
|
||||
color={colors.accent.purple}
|
||||
css={css`filter: drop-shadow(0 0 6px #8b5cf6);`}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.3);`}
|
||||
>
|
||||
大盘分时走势
|
||||
</Text>
|
||||
<Tooltip label="图表上的标记点表示概念异动时刻,点击可查看详情" hasArrow>
|
||||
<Icon as={Info} boxSize={3.5} color={colors.text.muted} cursor="help" />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<IndexMinuteChart
|
||||
indexData={index}
|
||||
alerts={alerts}
|
||||
onAlertClick={handleChartAlertClick}
|
||||
height="420px"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 异动列表 - Glassmorphism 横向滚动 */}
|
||||
{alerts.length > 0 && (
|
||||
<Box>
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="12px"
|
||||
bg="rgba(249, 115, 22, 0.15)"
|
||||
border="1px solid rgba(249, 115, 22, 0.25)"
|
||||
>
|
||||
<Icon
|
||||
as={List}
|
||||
boxSize={5}
|
||||
color={colors.accent.orange}
|
||||
css={css`filter: drop-shadow(0 0 6px #f97316);`}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>(点击卡片查看详情)</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 横向滚动卡片 */}
|
||||
<Box
|
||||
overflowX="auto"
|
||||
pb={3}
|
||||
sx={{
|
||||
'&::-webkit-scrollbar': { height: '6px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.02)', borderRadius: '3px' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarColor,
|
||||
borderRadius: '3px',
|
||||
'&:hover': { background: 'rgba(139, 92, 246, 0.5)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HStack spacing={3} pb={1}>
|
||||
{[...alerts]
|
||||
.sort((a, b) => (b.time || '').localeCompare(a.time || ''))
|
||||
.map((alert, idx) => (
|
||||
<CompactAlertCard
|
||||
key={`${alert.concept_id}-${alert.time}-${idx}`}
|
||||
alert={alert}
|
||||
onClick={handleCardAlertClick}
|
||||
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 无异动提示 - Glassmorphism */}
|
||||
{alerts.length === 0 && (
|
||||
<Center
|
||||
py={12}
|
||||
bg={sectionBg}
|
||||
backdropFilter={glassEffect.light.backdropFilter}
|
||||
borderRadius="20px"
|
||||
border={glassEffect.light.border}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 背景光晕 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
w="150px"
|
||||
h="150px"
|
||||
borderRadius="full"
|
||||
bg="rgba(139, 92, 246, 0.1)"
|
||||
filter="blur(40px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<VStack spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="full"
|
||||
bg="rgba(139, 92, 246, 0.1)"
|
||||
border="1px solid rgba(139, 92, 246, 0.2)"
|
||||
>
|
||||
<Icon
|
||||
as={Zap}
|
||||
boxSize={8}
|
||||
color={colors.accent.purple}
|
||||
opacity={0.6}
|
||||
css={css`filter: drop-shadow(0 0 10px rgba(139, 92, 246, 0.3));`}
|
||||
/>
|
||||
</Box>
|
||||
<Text color={colors.text.tertiary} fontSize="sm">当日暂无概念异动数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 异动详情右边栏抽屉 */}
|
||||
<AlertDetailDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={onDrawerClose}
|
||||
alertData={drawerAlertData}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotspotOverview;
|
||||
Reference in New Issue
Block a user