Merge branch 'feature_bugfix/20260106' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/20260106

This commit is contained in:
2026-01-07 11:58:26 +08:00
922 changed files with 451750 additions and 129 deletions

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

View 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>
);
}

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

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

View 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>
);
}

View 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>
);
}

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

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

View 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
View File

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

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

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

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

View 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_dataSectorDetails 组件需要的格式)
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
View 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: '获取成功'
});
}),
];

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

View 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
View 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; // 已完成
}

View 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();
};

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

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

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

View File

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

View 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>
);
}

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { useHotspotData } from './useHotspotData';

View File

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

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