添加重构后的组件目录
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 单个高位股项组件
|
||||||
|
* 显示股票名称、连板数、风险等级
|
||||||
|
*/
|
||||||
|
import React, { memo } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Icon,
|
||||||
|
Link,
|
||||||
|
Tooltip,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Flame } from "lucide-react";
|
||||||
|
import { css } from "@emotion/react";
|
||||||
|
import type { HighPositionStock } from "@/types/limitAnalyse";
|
||||||
|
import { getRiskLevel } from "@/utils/limitAnalyseUtils";
|
||||||
|
import { goldColors, textColors, pulse } from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
/** 高位股警示背景色 */
|
||||||
|
const warningBgColors = {
|
||||||
|
hover: "rgba(40, 25, 30, 0.9)",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HighPositionItemProps {
|
||||||
|
/** 股票数据 */
|
||||||
|
stock: HighPositionStock;
|
||||||
|
/** 列表索引 */
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个高位股项
|
||||||
|
*/
|
||||||
|
const HighPositionItem = memo<HighPositionItemProps>(({ stock, index }) => {
|
||||||
|
const riskInfo = getRiskLevel(stock.continuous_limit_up);
|
||||||
|
const RiskIcon = riskInfo.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={3}
|
||||||
|
bg={index === 0 ? "rgba(239, 68, 68, 0.08)" : "transparent"}
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.04)"
|
||||||
|
borderLeft={`3px solid ${riskInfo.color}`}
|
||||||
|
_hover={{ bg: warningBgColors.hover }}
|
||||||
|
transition="all 0.15s"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" align="flex-start">
|
||||||
|
{/* 左侧:序号 + 名称 */}
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color={textColors.muted}
|
||||||
|
fontWeight="500"
|
||||||
|
w="20px"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
<VStack align="start" spacing={0}>
|
||||||
|
<Link
|
||||||
|
href={`/company?scode=${stock.stock_code}`}
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
color={goldColors.light}
|
||||||
|
_hover={{ color: goldColors.primary }}
|
||||||
|
>
|
||||||
|
{stock.stock_name}
|
||||||
|
</Link>
|
||||||
|
<Text fontSize="10px" color={textColors.muted}>
|
||||||
|
({riskInfo.status})
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 右侧:连板数 + 风险图标 */}
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Badge
|
||||||
|
bg={riskInfo.bg}
|
||||||
|
color={riskInfo.color}
|
||||||
|
border={`1px solid ${riskInfo.border}`}
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="md"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={1}
|
||||||
|
css={
|
||||||
|
stock.continuous_limit_up >= 7
|
||||||
|
? css`
|
||||||
|
animation: ${pulse} 2s ease-in-out infinite;
|
||||||
|
`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stock.continuous_limit_up >= 5 && <Icon as={Flame} boxSize={3} />}
|
||||||
|
{stock.continuous_limit_up}连板
|
||||||
|
</Badge>
|
||||||
|
<Tooltip label={`风险等级: ${riskInfo.level}`} placement="left">
|
||||||
|
<Icon as={RiskIcon} boxSize={4} color={riskInfo.color} />
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HighPositionItem.displayName = "HighPositionItem";
|
||||||
|
|
||||||
|
export default HighPositionItem;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 风险风向标 - 数字卡片样式
|
||||||
|
* 显示高位股统计数据
|
||||||
|
*/
|
||||||
|
import React, { memo } from "react";
|
||||||
|
import { Box, HStack, Text } from "@chakra-ui/react";
|
||||||
|
import type { HighPositionStatistics } from "@/types/limitAnalyse";
|
||||||
|
import { textColors } from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
interface RiskVaneProps {
|
||||||
|
/** 统计数据 */
|
||||||
|
statistics: HighPositionStatistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片配置类型 */
|
||||||
|
interface CardConfig {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险风向标组件
|
||||||
|
*/
|
||||||
|
const RiskVane = memo<RiskVaneProps>(({ statistics }) => {
|
||||||
|
const avgDays = statistics?.avg_continuous_days || 0;
|
||||||
|
const maxDays = statistics?.max_continuous_days || 0;
|
||||||
|
const totalCount = statistics?.total_count || 0;
|
||||||
|
|
||||||
|
const cards: CardConfig[] = [
|
||||||
|
{
|
||||||
|
label: "高位股",
|
||||||
|
value: totalCount,
|
||||||
|
unit: "只",
|
||||||
|
color: textColors.primary,
|
||||||
|
bg: "rgba(255, 255, 255, 0.05)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "平均连板",
|
||||||
|
value: avgDays,
|
||||||
|
unit: "",
|
||||||
|
color: "#f97316",
|
||||||
|
bg: "rgba(249, 115, 22, 0.1)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "最高连板",
|
||||||
|
value: maxDays,
|
||||||
|
unit: "板",
|
||||||
|
color: "#ef4444",
|
||||||
|
bg: "rgba(239, 68, 68, 0.1)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack spacing={2}>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Box
|
||||||
|
key={card.label}
|
||||||
|
flex={1}
|
||||||
|
p={2}
|
||||||
|
borderRadius="10px"
|
||||||
|
bg={card.bg}
|
||||||
|
border="1px solid rgba(255, 255, 255, 0.06)"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color={card.color}>
|
||||||
|
{card.value}
|
||||||
|
<Text as="span" fontSize="xs" fontWeight="normal">
|
||||||
|
{card.unit}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="10px" color={textColors.muted}>
|
||||||
|
{card.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RiskVane.displayName = "RiskVane";
|
||||||
|
|
||||||
|
export default RiskVane;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 风险提示组件
|
||||||
|
* 底部风险警告栏
|
||||||
|
*/
|
||||||
|
import React, { memo } from "react";
|
||||||
|
import { Box, HStack, Text, Icon } from "@chakra-ui/react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface RiskWarningProps {
|
||||||
|
/** 警告文本 */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险提示组件
|
||||||
|
*/
|
||||||
|
const RiskWarning = memo<RiskWarningProps>(
|
||||||
|
({ text = "高位股风险较高,追涨需谨慎" }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={2}
|
||||||
|
bg="rgba(239, 68, 68, 0.08)"
|
||||||
|
borderTop="1px solid rgba(239, 68, 68, 0.1)"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<HStack spacing={2} fontSize="10px" color="#ef4444">
|
||||||
|
<Icon as={AlertTriangle} boxSize={3} />
|
||||||
|
<Text>{text}</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
RiskWarning.displayName = "RiskWarning";
|
||||||
|
|
||||||
|
export default RiskWarning;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* HighPositionSidebar 子组件导出
|
||||||
|
*/
|
||||||
|
export { default as HighPositionItem } from "./HighPositionItem";
|
||||||
|
export { default as RiskVane } from "./RiskVane";
|
||||||
|
export { default as RiskWarning } from "./RiskWarning";
|
||||||
205
src/views/LimitAnalyse/components/HighPositionSidebar/index.tsx
Normal file
205
src/views/LimitAnalyse/components/HighPositionSidebar/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 高位股统计 - 侧边栏风格
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* - 作为右侧警示区(40%宽度)
|
||||||
|
* - 风险风向标 (Risk Vane) 指示器
|
||||||
|
* - 紧凑的高位股列表,显示连板数和状态
|
||||||
|
* - 背景微带警示红色
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Skeleton,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { css } from "@emotion/react";
|
||||||
|
import { GLASS_BLUR } from "@/constants/glassConfig";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import ztStaticService from "@/services/ztStaticService";
|
||||||
|
import type { HighPositionStock, HighPositionStatistics } from "@/types/limitAnalyse";
|
||||||
|
import { getRiskAssessment } from "@/utils/limitAnalyseUtils";
|
||||||
|
import { textColors, shimmer } from "@/constants/limitAnalyseTheme";
|
||||||
|
import { thinScrollbarStyles } from "@/styles/limitAnalyseStyles";
|
||||||
|
import { HighPositionItem, RiskWarning } from "./components";
|
||||||
|
|
||||||
|
/** 高位股警示背景色 */
|
||||||
|
const warningBgColors = {
|
||||||
|
card: "rgba(20, 15, 18, 0.95)",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HighPositionData {
|
||||||
|
stocks: HighPositionStock[];
|
||||||
|
statistics: HighPositionStatistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HighPositionSidebarProps {
|
||||||
|
/** 日期字符串 */
|
||||||
|
dateStr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高位股侧边栏主组件
|
||||||
|
*/
|
||||||
|
const HighPositionSidebar: React.FC<HighPositionSidebarProps> = ({ dateStr }) => {
|
||||||
|
const [data, setData] = useState<HighPositionData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 按连板数排序
|
||||||
|
const sortedStocks = useMemo(() => {
|
||||||
|
if (!data?.stocks) return [];
|
||||||
|
return [...data.stocks].sort(
|
||||||
|
(a, b) => b.continuous_limit_up - a.continuous_limit_up
|
||||||
|
);
|
||||||
|
}, [data?.stocks]);
|
||||||
|
|
||||||
|
// 数据获取
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await ztStaticService.fetchHighPositionStocks(dateStr);
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("HighPositionSidebar", "fetchData", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dateStr]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dateStr) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [dateStr, fetchData]);
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={warningBgColors.card}
|
||||||
|
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
|
||||||
|
borderRadius="20px"
|
||||||
|
border="1px solid rgba(239, 68, 68, 0.15)"
|
||||||
|
p={4}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Skeleton height="20px" width="60%" mb={4} />
|
||||||
|
<Skeleton height="100px" mb={4} />
|
||||||
|
<VStack spacing={3}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} height="50px" width="100%" />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空数据状态
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={warningBgColors.card}
|
||||||
|
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
|
||||||
|
borderRadius="20px"
|
||||||
|
border="1px solid rgba(239, 68, 68, 0.15)"
|
||||||
|
p={6}
|
||||||
|
h="100%"
|
||||||
|
>
|
||||||
|
<Text color={textColors.muted} textAlign="center">
|
||||||
|
暂无高位股数据
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { statistics } = data;
|
||||||
|
const riskAssessment = getRiskAssessment(statistics);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={warningBgColors.card}
|
||||||
|
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
|
||||||
|
borderRadius="20px"
|
||||||
|
border="1px solid rgba(239, 68, 68, 0.15)"
|
||||||
|
overflow="hidden"
|
||||||
|
h="100%"
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
{/* 顶部警示装饰条 */}
|
||||||
|
<Box
|
||||||
|
h="3px"
|
||||||
|
bgGradient="linear(to-r, transparent, #ef4444, #f97316, #ef4444, transparent)"
|
||||||
|
backgroundSize="200% 100%"
|
||||||
|
css={css`
|
||||||
|
animation: ${shimmer} 3s linear infinite;
|
||||||
|
`}
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={2}
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.06)"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" align="center">
|
||||||
|
<Text fontSize="md" fontWeight="bold" color={textColors.primary}>
|
||||||
|
高位股统计
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={3} fontSize="xs">
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text color={textColors.muted}>高位股</Text>
|
||||||
|
<Text color="#ef4444" fontWeight="bold">
|
||||||
|
{statistics.total_count}只
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text color={textColors.muted}>平均</Text>
|
||||||
|
<Text color="#f97316" fontWeight="bold">
|
||||||
|
{statistics.avg_continuous_days || 0}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text color={textColors.muted}>最高</Text>
|
||||||
|
<Text color="#ef4444" fontWeight="bold">
|
||||||
|
{statistics.max_continuous_days || 0}板
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Badge
|
||||||
|
bg={`${riskAssessment.color}20`}
|
||||||
|
color={riskAssessment.color}
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
{riskAssessment.level}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 高位股列表 */}
|
||||||
|
<Box flex={1} overflowY="auto" sx={thinScrollbarStyles}>
|
||||||
|
{sortedStocks.map((stock, index) => (
|
||||||
|
<HighPositionItem
|
||||||
|
key={stock.stock_code}
|
||||||
|
stock={stock}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 底部风险提示 */}
|
||||||
|
<RiskWarning />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HighPositionSidebar;
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* 高位股表格组件
|
||||||
|
* 使用 Ant Design Table 展示高位股列表
|
||||||
|
*/
|
||||||
|
import React, { memo, useMemo } from "react";
|
||||||
|
import { Box, HStack, Icon } from "@chakra-ui/react";
|
||||||
|
import { Table, Tag } from "antd";
|
||||||
|
import type { ColumnsType } from "antd/es/table";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
|
import type { HighPositionStock } from "@/types/limitAnalyse";
|
||||||
|
import { goldColors } from "@/constants/limitAnalyseTheme";
|
||||||
|
import { antTableDarkStyles } from "@/styles/limitAnalyseStyles";
|
||||||
|
|
||||||
|
interface HighPositionTableProps {
|
||||||
|
/** 股票列表 */
|
||||||
|
stocks: HighPositionStock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取连板风险徽章样式 */
|
||||||
|
function getContinuousDaysBadge(days: number) {
|
||||||
|
if (days >= 5)
|
||||||
|
return { bg: "rgba(239, 68, 68, 0.2)", color: "#ef4444", label: "高风险" };
|
||||||
|
if (days >= 3)
|
||||||
|
return { bg: "rgba(249, 115, 22, 0.2)", color: "#f97316", label: "中风险" };
|
||||||
|
if (days >= 2)
|
||||||
|
return { bg: "rgba(234, 179, 8, 0.2)", color: "#eab308", label: "低风险" };
|
||||||
|
return { bg: "rgba(34, 197, 94, 0.2)", color: "#22c55e", label: "正常" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格列配置 */
|
||||||
|
const createTableColumns = (): ColumnsType<HighPositionStock> => [
|
||||||
|
{
|
||||||
|
title: "股票代码",
|
||||||
|
dataIndex: "stock_code",
|
||||||
|
key: "stock_code",
|
||||||
|
width: 100,
|
||||||
|
render: (text: string) => (
|
||||||
|
<span style={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.9)" }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "股票名称",
|
||||||
|
dataIndex: "stock_name",
|
||||||
|
key: "stock_name",
|
||||||
|
width: 120,
|
||||||
|
render: (text: string, record: HighPositionStock) => (
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<span style={{ color: "#60a5fa", fontWeight: 500 }}>{text}</span>
|
||||||
|
{record.continuous_limit_up >= 5 && (
|
||||||
|
<Icon as={Star} boxSize={3} color="#ef4444" fill="#ef4444" />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "价格",
|
||||||
|
dataIndex: "price",
|
||||||
|
key: "price",
|
||||||
|
width: 90,
|
||||||
|
align: "right",
|
||||||
|
render: (text: number) => (
|
||||||
|
<span style={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.9)" }}>
|
||||||
|
¥{text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "涨幅",
|
||||||
|
dataIndex: "increase_rate",
|
||||||
|
key: "increase_rate",
|
||||||
|
width: 80,
|
||||||
|
align: "right",
|
||||||
|
sorter: (a, b) => a.increase_rate - b.increase_rate,
|
||||||
|
render: (text: number) => (
|
||||||
|
<span
|
||||||
|
style={{ fontWeight: 600, color: text > 0 ? "#ef4444" : "#22c55e" }}
|
||||||
|
>
|
||||||
|
{text > 0 ? "+" : ""}
|
||||||
|
{text}%
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "连板",
|
||||||
|
dataIndex: "continuous_limit_up",
|
||||||
|
key: "continuous_limit_up",
|
||||||
|
width: 80,
|
||||||
|
align: "center",
|
||||||
|
sorter: (a, b) => a.continuous_limit_up - b.continuous_limit_up,
|
||||||
|
defaultSortOrder: "descend",
|
||||||
|
render: (days: number) => {
|
||||||
|
const riskInfo = getContinuousDaysBadge(days);
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
style={{
|
||||||
|
background: riskInfo.bg,
|
||||||
|
color: riskInfo.color,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{days}天
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "风险",
|
||||||
|
dataIndex: "continuous_limit_up",
|
||||||
|
key: "risk",
|
||||||
|
width: 70,
|
||||||
|
render: (days: number) => {
|
||||||
|
const riskInfo = getContinuousDaysBadge(days);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{ fontSize: "12px", color: riskInfo.color, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{riskInfo.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "行业",
|
||||||
|
dataIndex: "industry",
|
||||||
|
key: "industry",
|
||||||
|
width: 100,
|
||||||
|
render: (text: string) => (
|
||||||
|
<Tag
|
||||||
|
style={{
|
||||||
|
background: "rgba(96, 165, 250, 0.1)",
|
||||||
|
color: "#60a5fa",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "换手率",
|
||||||
|
dataIndex: "turnover_rate",
|
||||||
|
key: "turnover_rate",
|
||||||
|
width: 80,
|
||||||
|
align: "right",
|
||||||
|
sorter: (a, b) => a.turnover_rate - b.turnover_rate,
|
||||||
|
render: (text: number) => (
|
||||||
|
<span style={{ color: "rgba(255, 255, 255, 0.7)" }}>{text}%</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高位股表格组件
|
||||||
|
*/
|
||||||
|
const HighPositionTable = memo<HighPositionTableProps>(({ stocks }) => {
|
||||||
|
const columns = useMemo(() => createTableColumns(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderRadius="12px"
|
||||||
|
overflow="hidden"
|
||||||
|
sx={{
|
||||||
|
...antTableDarkStyles,
|
||||||
|
".ant-table-thead > tr > th": {
|
||||||
|
...antTableDarkStyles[".ant-table-thead > tr > th"],
|
||||||
|
padding: "12px 16px !important",
|
||||||
|
},
|
||||||
|
".ant-table-tbody > tr > td": {
|
||||||
|
...antTableDarkStyles[".ant-table-tbody > tr > td"],
|
||||||
|
padding: "12px 16px !important",
|
||||||
|
},
|
||||||
|
".ant-pagination-item-active": {
|
||||||
|
background: "rgba(212, 175, 55, 0.2)",
|
||||||
|
borderColor: goldColors.primary,
|
||||||
|
a: { color: goldColors.primary },
|
||||||
|
},
|
||||||
|
".ant-pagination-prev, .ant-pagination-next": {
|
||||||
|
".ant-pagination-item-link": {
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={stocks}
|
||||||
|
rowKey="stock_code"
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (total) => `共 ${total} 只`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HighPositionTable.displayName = "HighPositionTable";
|
||||||
|
|
||||||
|
export default HighPositionTable;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 风险徽章组件
|
||||||
|
* 显示高位股统计的多个徽章
|
||||||
|
*/
|
||||||
|
import React, { memo } from "react";
|
||||||
|
import { HStack, Badge, IconButton, Icon } from "@chakra-ui/react";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import type { HighPositionStatistics } from "@/types/limitAnalyse";
|
||||||
|
import { goldColors } from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
interface RiskBadgesProps {
|
||||||
|
/** 统计数据 */
|
||||||
|
statistics: HighPositionStatistics;
|
||||||
|
/** 是否展开 */
|
||||||
|
isExpanded: boolean;
|
||||||
|
/** 切换展开状态 */
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险徽章组件
|
||||||
|
*/
|
||||||
|
const RiskBadges = memo<RiskBadgesProps>(
|
||||||
|
({ statistics, isExpanded, onToggle }) => {
|
||||||
|
return (
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Badge
|
||||||
|
bg="rgba(239, 68, 68, 0.15)"
|
||||||
|
color="#ef4444"
|
||||||
|
fontSize="sm"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
border="1px solid rgba(239, 68, 68, 0.3)"
|
||||||
|
>
|
||||||
|
{statistics.total_count} 只高位股
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
bg="rgba(249, 115, 22, 0.15)"
|
||||||
|
color="#f97316"
|
||||||
|
fontSize="sm"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
border="1px solid rgba(249, 115, 22, 0.3)"
|
||||||
|
>
|
||||||
|
平均 {statistics.avg_continuous_days} 天
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
bg="rgba(212, 175, 55, 0.15)"
|
||||||
|
color={goldColors.primary}
|
||||||
|
fontSize="sm"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
border="1px solid rgba(212, 175, 55, 0.3)"
|
||||||
|
>
|
||||||
|
最高 {statistics.max_continuous_days} 连板
|
||||||
|
</Badge>
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
isExpanded ? (
|
||||||
|
<Icon as={ChevronUp} boxSize={5} />
|
||||||
|
) : (
|
||||||
|
<Icon as={ChevronDown} boxSize={5} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="ghost"
|
||||||
|
color="rgba(255, 255, 255, 0.6)"
|
||||||
|
size="sm"
|
||||||
|
_hover={{ color: goldColors.primary, bg: "rgba(255, 255, 255, 0.05)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
aria-label={isExpanded ? "收起" : "展开"}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
RiskBadges.displayName = "RiskBadges";
|
||||||
|
|
||||||
|
export default RiskBadges;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 统计卡片组件
|
||||||
|
* 显示单个统计指标
|
||||||
|
*/
|
||||||
|
import React, { memo } from "react";
|
||||||
|
import { Box, Text } from "@chakra-ui/react";
|
||||||
|
import { statisticsCardStyles } from "@/styles/limitAnalyseStyles";
|
||||||
|
|
||||||
|
interface StatisticsCardProps {
|
||||||
|
/** 标签文字 */
|
||||||
|
label: string;
|
||||||
|
/** 数值 */
|
||||||
|
value: number | string;
|
||||||
|
/** 单位 */
|
||||||
|
unit?: string;
|
||||||
|
/** 数值颜色 */
|
||||||
|
valueColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计卡片组件
|
||||||
|
*/
|
||||||
|
const StatisticsCard = memo<StatisticsCardProps>(
|
||||||
|
({ label, value, unit, valueColor = "#ef4444" }) => {
|
||||||
|
return (
|
||||||
|
<Box {...statisticsCardStyles.container}>
|
||||||
|
<Text {...statisticsCardStyles.label}>{label}</Text>
|
||||||
|
<Text {...statisticsCardStyles.value} color={valueColor}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{unit && <Text {...statisticsCardStyles.unit}>{unit}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
StatisticsCard.displayName = "StatisticsCard";
|
||||||
|
|
||||||
|
export default StatisticsCard;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* HighPositionStocks 子组件导出
|
||||||
|
*/
|
||||||
|
export { default as StatisticsCard } from "./StatisticsCard";
|
||||||
|
export { default as RiskBadges } from "./RiskBadges";
|
||||||
|
export { default as HighPositionTable } from "./HighPositionTable";
|
||||||
243
src/views/LimitAnalyse/components/HighPositionStocks/index.tsx
Normal file
243
src/views/LimitAnalyse/components/HighPositionStocks/index.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* 高位股统计组件
|
||||||
|
* 展示连续涨停股票风险监控
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
Icon,
|
||||||
|
Collapse,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { TrendingUp, Sparkles } from "lucide-react";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import ztStaticService from "@/services/ztStaticService";
|
||||||
|
import { GLASS_BLUR } from "@/constants/glassConfig";
|
||||||
|
import type { HighPositionStock, HighPositionStatistics } from "@/types/limitAnalyse";
|
||||||
|
import { goldColors } from "@/constants/limitAnalyseTheme";
|
||||||
|
import { riskWarningStyles } from "@/styles/limitAnalyseStyles";
|
||||||
|
import { StatisticsCard, RiskBadges, HighPositionTable } from "./components";
|
||||||
|
|
||||||
|
/** 玻璃拟态样式 */
|
||||||
|
const glassStyle = {
|
||||||
|
bg: "rgba(15, 15, 22, 0.9)",
|
||||||
|
backdropFilter: `${GLASS_BLUR.lg} saturate(180%)`,
|
||||||
|
border: "1px solid rgba(212, 175, 55, 0.15)",
|
||||||
|
borderRadius: "20px",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HighPositionData {
|
||||||
|
stocks: HighPositionStock[];
|
||||||
|
statistics: HighPositionStatistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HighPositionStocksProps {
|
||||||
|
/** 日期字符串 */
|
||||||
|
dateStr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高位股统计组件
|
||||||
|
*/
|
||||||
|
const HighPositionStocks: React.FC<HighPositionStocksProps> = ({ dateStr }) => {
|
||||||
|
const [highPositionData, setHighPositionData] =
|
||||||
|
useState<HighPositionData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
// 数据获取
|
||||||
|
const fetchHighPositionStocks = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await ztStaticService.fetchHighPositionStocks(dateStr);
|
||||||
|
|
||||||
|
logger.debug("HighPositionStocks", "静态数据响应", {
|
||||||
|
date: dateStr,
|
||||||
|
success: data.success,
|
||||||
|
stockCount: data.data?.stocks?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setHighPositionData(data.data);
|
||||||
|
} else {
|
||||||
|
logger.warn("HighPositionStocks", "数据获取失败", {
|
||||||
|
date: dateStr,
|
||||||
|
error: (data as { error?: string }).error,
|
||||||
|
});
|
||||||
|
setHighPositionData(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("HighPositionStocks", "fetchHighPositionStocks", error, {
|
||||||
|
date: dateStr,
|
||||||
|
});
|
||||||
|
setHighPositionData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dateStr]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dateStr) {
|
||||||
|
fetchHighPositionStocks();
|
||||||
|
}
|
||||||
|
}, [dateStr, fetchHighPositionStocks]);
|
||||||
|
|
||||||
|
// 切换展开状态
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setIsExpanded((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box {...glassStyle} p={6} mb={6}>
|
||||||
|
<Skeleton
|
||||||
|
height="30px"
|
||||||
|
width="200px"
|
||||||
|
mb={4}
|
||||||
|
startColor="rgba(255,255,255,0.05)"
|
||||||
|
endColor="rgba(255,255,255,0.1)"
|
||||||
|
/>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
height="80px"
|
||||||
|
borderRadius="lg"
|
||||||
|
startColor="rgba(255,255,255,0.05)"
|
||||||
|
endColor="rgba(255,255,255,0.1)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Skeleton
|
||||||
|
height="300px"
|
||||||
|
borderRadius="lg"
|
||||||
|
startColor="rgba(255,255,255,0.05)"
|
||||||
|
endColor="rgba(255,255,255,0.1)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空数据状态
|
||||||
|
if (!highPositionData) {
|
||||||
|
return (
|
||||||
|
<Box {...glassStyle} p={6} mb={6}>
|
||||||
|
<Text textAlign="center" color="rgba(255, 255, 255, 0.5)">
|
||||||
|
暂无高位股数据
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stocks, statistics } = highPositionData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box {...glassStyle} position="relative" overflow="hidden" mb={6}>
|
||||||
|
{/* 顶部金色装饰条 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
h="3px"
|
||||||
|
bgGradient={`linear(to-r, transparent, ${goldColors.primary}, ${goldColors.light}, ${goldColors.primary}, transparent)`}
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<HStack
|
||||||
|
px={6}
|
||||||
|
py={4}
|
||||||
|
justify="space-between"
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.06)"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={handleToggle}
|
||||||
|
_hover={{ bg: "rgba(255, 255, 255, 0.02)" }}
|
||||||
|
transition="background 0.2s"
|
||||||
|
>
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Box
|
||||||
|
p={2}
|
||||||
|
borderRadius="12px"
|
||||||
|
bg={`linear-gradient(135deg, ${goldColors.dark}, ${goldColors.primary})`}
|
||||||
|
boxShadow={`0 4px 15px ${goldColors.glow}`}
|
||||||
|
>
|
||||||
|
<Icon as={TrendingUp} boxSize={5} color="white" />
|
||||||
|
</Box>
|
||||||
|
<VStack align="start" spacing={0}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="700"
|
||||||
|
color="rgba(255, 255, 255, 0.95)"
|
||||||
|
letterSpacing="0.5px"
|
||||||
|
textShadow={`0 0 20px ${goldColors.glow}`}
|
||||||
|
>
|
||||||
|
高位股统计
|
||||||
|
</Text>
|
||||||
|
<Icon as={Sparkles} boxSize={4} color={goldColors.primary} />
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)">
|
||||||
|
连续涨停股票风险监控
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<RiskBadges
|
||||||
|
statistics={statistics}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Collapse in={isExpanded} animateOpacity>
|
||||||
|
<Box p={5}>
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
|
||||||
|
<StatisticsCard
|
||||||
|
label="高位股总数"
|
||||||
|
value={statistics.total_count}
|
||||||
|
unit="连续涨停股票"
|
||||||
|
valueColor="#ef4444"
|
||||||
|
/>
|
||||||
|
<StatisticsCard
|
||||||
|
label="平均连板天数"
|
||||||
|
value={statistics.avg_continuous_days}
|
||||||
|
unit="天"
|
||||||
|
valueColor="#f97316"
|
||||||
|
/>
|
||||||
|
<StatisticsCard
|
||||||
|
label="最高连板"
|
||||||
|
value={statistics.max_continuous_days}
|
||||||
|
unit="天"
|
||||||
|
valueColor={goldColors.primary}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 高位股列表 */}
|
||||||
|
<HighPositionTable stocks={stocks} />
|
||||||
|
|
||||||
|
{/* 风险提示 */}
|
||||||
|
<Box {...riskWarningStyles.container}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={TrendingUp} boxSize={4} color="#eab308" />
|
||||||
|
<Text fontSize="xs" color="rgba(255, 255, 255, 0.7)">
|
||||||
|
<Text as="span" fontWeight="600" color="#eab308">
|
||||||
|
风险提示:
|
||||||
|
</Text>
|
||||||
|
高位股通常具有较高的波动性和风险,请谨慎投资。连续涨停天数越多,风险相对越高。
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HighPositionStocks;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 排序按钮组组件
|
||||||
|
*/
|
||||||
|
import React, { memo, useCallback } from "react";
|
||||||
|
import { Button, ButtonGroup } from "@chakra-ui/react";
|
||||||
|
import { goldColors, textColors, marketColors } from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
/** 排序类型 */
|
||||||
|
export type SortType = "change" | "board" | "dragon";
|
||||||
|
|
||||||
|
interface SortButtonGroupProps {
|
||||||
|
/** 当前排序类型 */
|
||||||
|
sortType: SortType;
|
||||||
|
/** 排序类型变更回调 */
|
||||||
|
onSortChange: (type: SortType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按钮配置 */
|
||||||
|
const SORT_BUTTONS: Array<{
|
||||||
|
type: SortType;
|
||||||
|
label: string;
|
||||||
|
activeColor: string;
|
||||||
|
activeBg: string;
|
||||||
|
activeBorder: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
type: "change",
|
||||||
|
label: "按涨幅↓",
|
||||||
|
activeColor: marketColors.up,
|
||||||
|
activeBg: "rgba(239, 68, 68, 0.2)",
|
||||||
|
activeBorder: "rgba(239, 68, 68, 0.4)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "board",
|
||||||
|
label: "按连板数",
|
||||||
|
activeColor: "#f97316",
|
||||||
|
activeBg: "rgba(249, 115, 22, 0.2)",
|
||||||
|
activeBorder: "rgba(249, 115, 22, 0.4)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "dragon",
|
||||||
|
label: "只看龙头",
|
||||||
|
activeColor: goldColors.primary,
|
||||||
|
activeBg: "rgba(212, 175, 55, 0.2)",
|
||||||
|
activeBorder: "rgba(212, 175, 55, 0.4)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序按钮组组件
|
||||||
|
*/
|
||||||
|
const SortButtonGroup = memo<SortButtonGroupProps>(
|
||||||
|
({ sortType, onSortChange }) => {
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(type: SortType) => {
|
||||||
|
onSortChange(type);
|
||||||
|
},
|
||||||
|
[onSortChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup size="xs" isAttached variant="outline">
|
||||||
|
{SORT_BUTTONS.map((btn) => {
|
||||||
|
const isActive = sortType === btn.type;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={btn.type}
|
||||||
|
onClick={() => handleClick(btn.type)}
|
||||||
|
bg={isActive ? btn.activeBg : "transparent"}
|
||||||
|
color={isActive ? btn.activeColor : textColors.secondary}
|
||||||
|
borderColor={
|
||||||
|
isActive ? btn.activeBorder : "rgba(255, 255, 255, 0.1)"
|
||||||
|
}
|
||||||
|
_hover={{ bg: `${btn.activeBg.replace("0.2", "0.15")}` }}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SortButtonGroup.displayName = "SortButtonGroup";
|
||||||
|
|
||||||
|
export default SortButtonGroup;
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* 股票行组件
|
||||||
|
* 显示单个股票的信息
|
||||||
|
*/
|
||||||
|
import React, { memo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Flex,
|
||||||
|
Link,
|
||||||
|
Icon,
|
||||||
|
Tag,
|
||||||
|
TagLabel,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Flame, ExternalLink } from "lucide-react";
|
||||||
|
import type { LimitUpStock, StockRole } from "@/types/limitAnalyse";
|
||||||
|
import { getBoardStyle } from "@/utils/limitAnalyseUtils";
|
||||||
|
import {
|
||||||
|
goldColors,
|
||||||
|
textColors,
|
||||||
|
marketColors,
|
||||||
|
bgColors,
|
||||||
|
} from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
/** 获取板块颜色 */
|
||||||
|
function getSectorTagColor(sector: string): string {
|
||||||
|
if (sector === "公告") return "#D4AF37";
|
||||||
|
if (sector === "其他") return "#9CA3AF";
|
||||||
|
if (
|
||||||
|
sector.includes("AI") ||
|
||||||
|
sector.includes("人工智能") ||
|
||||||
|
sector.includes("芯片")
|
||||||
|
)
|
||||||
|
return "#8B5CF6";
|
||||||
|
if (
|
||||||
|
sector.includes("锂电") ||
|
||||||
|
sector.includes("电池") ||
|
||||||
|
sector.includes("新能源")
|
||||||
|
)
|
||||||
|
return "#10B981";
|
||||||
|
if (sector.includes("医药") || sector.includes("医疗")) return "#EC4899";
|
||||||
|
if (sector.includes("金融") || sector.includes("银行")) return "#F59E0B";
|
||||||
|
if (sector.includes("军工") || sector.includes("航空")) return "#EF4444";
|
||||||
|
return "#06B6D4";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockRowProps {
|
||||||
|
/** 股票数据 */
|
||||||
|
stock: LimitUpStock;
|
||||||
|
/** 行索引 */
|
||||||
|
index: number;
|
||||||
|
/** 股票角色 */
|
||||||
|
role: StockRole;
|
||||||
|
/** 是否选中 */
|
||||||
|
isSelected: boolean;
|
||||||
|
/** 点击回调 */
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票行组件
|
||||||
|
*/
|
||||||
|
const StockRow = memo<StockRowProps>(
|
||||||
|
({ stock, index, role, isSelected, onClick }) => {
|
||||||
|
const boardStyle = getBoardStyle(
|
||||||
|
parseInt(stock.continuous_days || "1") || 1
|
||||||
|
);
|
||||||
|
const RoleIcon = role.icon;
|
||||||
|
|
||||||
|
const handleLinkClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={3}
|
||||||
|
bg={isSelected ? "rgba(96, 165, 250, 0.15)" : "transparent"}
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.04)"
|
||||||
|
borderLeft={`3px solid ${isSelected ? "#60a5fa" : "transparent"}`}
|
||||||
|
_hover={{ bg: bgColors.hover }}
|
||||||
|
transition="all 0.15s"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
{/* 左侧:序号 + 角色标签 + 名称 */}
|
||||||
|
<HStack spacing={3} flex={1}>
|
||||||
|
{/* 序号 */}
|
||||||
|
<Text
|
||||||
|
w="24px"
|
||||||
|
fontSize="sm"
|
||||||
|
color={textColors.muted}
|
||||||
|
fontWeight="500"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 角色标签 */}
|
||||||
|
<Badge
|
||||||
|
bg={role.bg}
|
||||||
|
color={role.color}
|
||||||
|
fontSize="10px"
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="md"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Icon as={RoleIcon} boxSize={3} />
|
||||||
|
{role.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* 股票名称 */}
|
||||||
|
<Link
|
||||||
|
href={`/company?scode=${stock.scode}`}
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
color={goldColors.light}
|
||||||
|
_hover={{ color: goldColors.primary, textDecoration: "underline" }}
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
{stock.sname}
|
||||||
|
<Icon as={ExternalLink} boxSize={3} ml={1} opacity={0.7} />
|
||||||
|
</Link>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 中间:涨幅 */}
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={marketColors.up}
|
||||||
|
minW="70px"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
+{stock.change_pct || "10.00"}%
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 右侧:连板标签 + 板块标签 */}
|
||||||
|
<HStack spacing={3} minW="200px" justify="flex-end">
|
||||||
|
{/* 连板标签 */}
|
||||||
|
<Badge
|
||||||
|
bg={`${boardStyle.color}20`}
|
||||||
|
color={boardStyle.color}
|
||||||
|
border={`1px solid ${boardStyle.color}40`}
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="md"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
{boardStyle.showFlame && <Icon as={Flame} boxSize={3} />}
|
||||||
|
{boardStyle.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* 核心板块标签 */}
|
||||||
|
{stock.core_sectors && stock.core_sectors.length > 0 && (
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg={`${getSectorTagColor(stock.core_sectors[0])}15`}
|
||||||
|
color={getSectorTagColor(stock.core_sectors[0])}
|
||||||
|
borderRadius="md"
|
||||||
|
maxW="80px"
|
||||||
|
>
|
||||||
|
<TagLabel fontSize="10px" isTruncated>
|
||||||
|
{stock.core_sectors[0]}
|
||||||
|
</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
StockRow.displayName = "StockRow";
|
||||||
|
|
||||||
|
export default StockRow;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 表头组件
|
||||||
|
*/
|
||||||
|
import React, { memo } from "react";
|
||||||
|
import { Box, Flex, Text } from "@chakra-ui/react";
|
||||||
|
import { textColors } from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头组件
|
||||||
|
*/
|
||||||
|
const TableHeader = memo(() => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={2}
|
||||||
|
bg="rgba(0, 0, 0, 0.2)"
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.06)"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<Flex>
|
||||||
|
<Text
|
||||||
|
flex={1}
|
||||||
|
fontSize="xs"
|
||||||
|
color={textColors.muted}
|
||||||
|
fontWeight="500"
|
||||||
|
>
|
||||||
|
# 名称
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
w="70px"
|
||||||
|
fontSize="xs"
|
||||||
|
color={textColors.muted}
|
||||||
|
fontWeight="500"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
涨幅
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
w="200px"
|
||||||
|
fontSize="xs"
|
||||||
|
color={textColors.muted}
|
||||||
|
fontWeight="500"
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
连板 板块
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
export default TableHeader;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* SectorMovementTable 子组件导出
|
||||||
|
*/
|
||||||
|
export { default as StockRow } from "./StockRow";
|
||||||
|
export { default as SortButtonGroup } from "./SortButtonGroup";
|
||||||
|
export type { SortType } from "./SortButtonGroup";
|
||||||
|
export { default as TableHeader } from "./TableHeader";
|
||||||
237
src/views/LimitAnalyse/components/SectorMovementTable/index.tsx
Normal file
237
src/views/LimitAnalyse/components/SectorMovementTable/index.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* 板块异动明细 - 表格化展示
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* - 表格化布局,显示当前选中板块的活跃股
|
||||||
|
* - 筛选按钮:按涨幅、按连板数、只看龙头
|
||||||
|
* - 股票角色标签:龙头、首板、中军、趋势等
|
||||||
|
* - 左侧占 60% 宽度,作为主要操作区
|
||||||
|
*/
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Flex,
|
||||||
|
Icon,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Filter } from "lucide-react";
|
||||||
|
import { css } from "@emotion/react";
|
||||||
|
import { GLASS_BLUR } from "@/constants/glassConfig";
|
||||||
|
import type { LimitUpStock, SortedSectors, StockRole } from "@/types/limitAnalyse";
|
||||||
|
import {
|
||||||
|
goldColors,
|
||||||
|
textColors,
|
||||||
|
shimmer,
|
||||||
|
borderColors,
|
||||||
|
} from "@/constants/limitAnalyseTheme";
|
||||||
|
import { getStockRole, SORT_STRATEGIES, parseContinuousDays } from "@/utils/limitAnalyseUtils";
|
||||||
|
import { scrollbarStyles } from "@/styles/limitAnalyseStyles";
|
||||||
|
import {
|
||||||
|
StockRow,
|
||||||
|
SortButtonGroup,
|
||||||
|
TableHeader,
|
||||||
|
type SortType,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
|
/** 获取板块颜色 */
|
||||||
|
function getSectorColor(sector: string): string {
|
||||||
|
if (sector === "公告") return "#D4AF37";
|
||||||
|
if (sector === "其他") return "#9CA3AF";
|
||||||
|
if (
|
||||||
|
sector.includes("AI") ||
|
||||||
|
sector.includes("人工智能") ||
|
||||||
|
sector.includes("芯片")
|
||||||
|
)
|
||||||
|
return "#8B5CF6";
|
||||||
|
if (
|
||||||
|
sector.includes("锂电") ||
|
||||||
|
sector.includes("电池") ||
|
||||||
|
sector.includes("新能源")
|
||||||
|
)
|
||||||
|
return "#10B981";
|
||||||
|
if (sector.includes("医药") || sector.includes("医疗")) return "#EC4899";
|
||||||
|
if (sector.includes("金融") || sector.includes("银行")) return "#F59E0B";
|
||||||
|
if (sector.includes("军工") || sector.includes("航空")) return "#EF4444";
|
||||||
|
return "#06B6D4";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectorMovementTableProps {
|
||||||
|
/** 排序后的板块数据 */
|
||||||
|
sortedSectors?: SortedSectors;
|
||||||
|
/** 总涨停股票数 */
|
||||||
|
totalStocks?: number;
|
||||||
|
/** 活跃板块数量 */
|
||||||
|
activeSectorCount?: number;
|
||||||
|
/** 当前选中的板块 */
|
||||||
|
selectedSector?: string | null;
|
||||||
|
/** 板块选中回调 */
|
||||||
|
onSectorSelect?: (sector: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 板块异动明细主组件
|
||||||
|
*/
|
||||||
|
const SectorMovementTable: React.FC<SectorMovementTableProps> = ({
|
||||||
|
sortedSectors = [],
|
||||||
|
totalStocks = 0,
|
||||||
|
activeSectorCount = 0,
|
||||||
|
selectedSector = null,
|
||||||
|
onSectorSelect,
|
||||||
|
}) => {
|
||||||
|
const [sortType, setSortType] = useState<SortType>("change");
|
||||||
|
const [selectedStock, setSelectedStock] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 获取当前选中板块的数据
|
||||||
|
const currentSectorData = useMemo(() => {
|
||||||
|
if (!selectedSector) {
|
||||||
|
return sortedSectors[0] || null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
sortedSectors.find(([name]) => name === selectedSector) ||
|
||||||
|
sortedSectors[0]
|
||||||
|
);
|
||||||
|
}, [sortedSectors, selectedSector]);
|
||||||
|
|
||||||
|
const currentSectorName = currentSectorData ? currentSectorData[0] : "";
|
||||||
|
|
||||||
|
// 当前板块的股票列表
|
||||||
|
const currentStocks = useMemo<LimitUpStock[]>(() => {
|
||||||
|
return currentSectorData ? currentSectorData[1].stocks || [] : [];
|
||||||
|
}, [currentSectorData]);
|
||||||
|
|
||||||
|
// 筛选和排序股票
|
||||||
|
const filteredStocks = useMemo(() => {
|
||||||
|
let stocks = [...currentStocks];
|
||||||
|
|
||||||
|
if (sortType === "change") {
|
||||||
|
stocks.sort(SORT_STRATEGIES.change);
|
||||||
|
} else if (sortType === "board") {
|
||||||
|
stocks.sort(SORT_STRATEGIES.board);
|
||||||
|
} else if (sortType === "dragon") {
|
||||||
|
stocks = stocks.filter((s) => parseContinuousDays(s.continuous_days) >= 2);
|
||||||
|
stocks.sort(SORT_STRATEGIES.board);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stocks;
|
||||||
|
}, [currentStocks, sortType]);
|
||||||
|
|
||||||
|
// 获取当前板块在列表中的索引
|
||||||
|
const currentSectorIndex = useMemo(() => {
|
||||||
|
return sortedSectors.findIndex(([name]) => name === currentSectorName);
|
||||||
|
}, [sortedSectors, currentSectorName]);
|
||||||
|
|
||||||
|
// 缓存角色计算结果
|
||||||
|
const roleCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, StockRole>();
|
||||||
|
filteredStocks.forEach((stock) => {
|
||||||
|
cache.set(
|
||||||
|
stock.scode,
|
||||||
|
getStockRole(stock, currentStocks, currentSectorIndex)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return cache;
|
||||||
|
}, [filteredStocks, currentStocks, currentSectorIndex]);
|
||||||
|
|
||||||
|
// 处理排序变更
|
||||||
|
const handleSortChange = useCallback((type: SortType) => {
|
||||||
|
setSortType(type);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理股票选中
|
||||||
|
const handleStockClick = useCallback((scode: string) => {
|
||||||
|
setSelectedStock(scode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="rgba(15, 15, 22, 0.9)"
|
||||||
|
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
|
||||||
|
borderRadius="20px"
|
||||||
|
border={`1px solid ${borderColors.gold}`}
|
||||||
|
overflow="hidden"
|
||||||
|
h="100%"
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
{/* 顶部金色装饰条 */}
|
||||||
|
<Box
|
||||||
|
h="3px"
|
||||||
|
bgGradient="linear(to-r, #B8860B, #D4AF37, #F4D03F, #D4AF37, #B8860B)"
|
||||||
|
backgroundSize="200% 100%"
|
||||||
|
css={css`
|
||||||
|
animation: ${shimmer} 3s linear infinite;
|
||||||
|
`}
|
||||||
|
flexShrink={0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<Box
|
||||||
|
px={5}
|
||||||
|
py={3}
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.06)"
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Text fontSize="md" fontWeight="bold" color={textColors.primary}>
|
||||||
|
板块异动明细
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color={textColors.muted}>
|
||||||
|
当前选中:
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
bg={`${getSectorColor(currentSectorName)}20`}
|
||||||
|
color={getSectorColor(currentSectorName)}
|
||||||
|
fontSize="sm"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="md"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{currentSectorName || "无"}
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="sm" color={textColors.muted}>
|
||||||
|
(共{filteredStocks.length}只活跃股)
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<SortButtonGroup sortType={sortType} onSortChange={handleSortChange} />
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 表头 */}
|
||||||
|
<TableHeader />
|
||||||
|
|
||||||
|
{/* 股票列表 */}
|
||||||
|
<Box flex={1} overflowY="auto" sx={scrollbarStyles}>
|
||||||
|
{filteredStocks.length === 0 ? (
|
||||||
|
<Flex justify="center" align="center" py={10}>
|
||||||
|
<VStack spacing={2}>
|
||||||
|
<Icon as={Filter} boxSize={8} color={textColors.muted} />
|
||||||
|
<Text color={textColors.muted}>暂无数据</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
filteredStocks.map((stock, index) => {
|
||||||
|
const role = roleCache.get(stock.scode) || getStockRole(stock, currentStocks, currentSectorIndex);
|
||||||
|
return (
|
||||||
|
<StockRow
|
||||||
|
key={stock.scode || index}
|
||||||
|
stock={stock}
|
||||||
|
index={index}
|
||||||
|
role={role}
|
||||||
|
isSelected={selectedStock === stock.scode}
|
||||||
|
onClick={() => handleStockClick(stock.scode)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectorMovementTable;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* 板块卡片头部组件
|
||||||
|
* 包含标题、统计徽章、搜索和排序
|
||||||
|
*/
|
||||||
|
import React, { memo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Flex,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
Select,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Flame, TrendingUp, Search, ArrowUpDown } from "lucide-react";
|
||||||
|
import { css } from "@emotion/react";
|
||||||
|
import type { SectorSortType } from "@/types/limitAnalyse";
|
||||||
|
import {
|
||||||
|
goldColors,
|
||||||
|
textColors,
|
||||||
|
bgColors,
|
||||||
|
marketColors,
|
||||||
|
borderColors,
|
||||||
|
} from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
interface SectorHeaderProps {
|
||||||
|
/** 显示标题 */
|
||||||
|
title: string;
|
||||||
|
/** 副标题 */
|
||||||
|
subtitle?: string;
|
||||||
|
/** 板块数量 */
|
||||||
|
sectorCount: number;
|
||||||
|
/** 涨停股票数 */
|
||||||
|
totalStocks: number;
|
||||||
|
/** 搜索文本 */
|
||||||
|
searchText: string;
|
||||||
|
/** 搜索文本变更 */
|
||||||
|
onSearchChange: (text: string) => void;
|
||||||
|
/** 排序类型 */
|
||||||
|
sortType: SectorSortType;
|
||||||
|
/** 排序类型变更 */
|
||||||
|
onSortChange: (type: SectorSortType) => void;
|
||||||
|
/** 显示模式 */
|
||||||
|
displayMode?: "accordion" | "card";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 板块卡片头部组件
|
||||||
|
*/
|
||||||
|
const SectorHeader = memo<SectorHeaderProps>(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
sectorCount,
|
||||||
|
totalStocks,
|
||||||
|
searchText,
|
||||||
|
onSearchChange,
|
||||||
|
sortType,
|
||||||
|
onSortChange,
|
||||||
|
displayMode = "card",
|
||||||
|
}) => {
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onSearchChange(e.target.value);
|
||||||
|
},
|
||||||
|
[onSearchChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSortChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
onSortChange(e.target.value as SectorSortType);
|
||||||
|
},
|
||||||
|
[onSortChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const HeaderIcon = displayMode === "accordion" ? Flame : TrendingUp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box px={6} py={4} borderBottom={`1px solid ${borderColors.primary}`}>
|
||||||
|
<Flex justify="space-between" align="center" mb={4}>
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Box
|
||||||
|
p={2.5}
|
||||||
|
borderRadius="12px"
|
||||||
|
bgGradient={`linear(to-br, ${goldColors.primary}, ${goldColors.dark})`}
|
||||||
|
boxShadow={`0 4px 15px ${goldColors.glow}`}
|
||||||
|
>
|
||||||
|
<Icon as={HeaderIcon} boxSize={5} color="white" />
|
||||||
|
</Box>
|
||||||
|
<VStack align="start" spacing={0}>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={goldColors.primary}
|
||||||
|
css={css`
|
||||||
|
text-shadow: 0 0 20px ${goldColors.glow};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text fontSize="xs" color={textColors.muted}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Badge
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
bg="rgba(212, 175, 55, 0.08)"
|
||||||
|
color={goldColors.primary}
|
||||||
|
border={`1px solid ${borderColors.gold}`}
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{sectorCount} 个板块
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
bg="rgba(239, 68, 68, 0.15)"
|
||||||
|
color={marketColors.up}
|
||||||
|
border="1px solid rgba(239, 68, 68, 0.3)"
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{totalStocks} 只涨停
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 搜索和排序 */}
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<InputGroup size="sm" maxW="240px">
|
||||||
|
<InputLeftElement>
|
||||||
|
<Icon as={Search} boxSize={4} color={textColors.muted} />
|
||||||
|
</InputLeftElement>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索板块或股票..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
bg={bgColors.item}
|
||||||
|
border={`1px solid ${borderColors.primary}`}
|
||||||
|
borderRadius="10px"
|
||||||
|
color={textColors.primary}
|
||||||
|
_placeholder={{ color: textColors.muted }}
|
||||||
|
_hover={{ borderColor: borderColors.gold }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: goldColors.primary,
|
||||||
|
boxShadow: `0 0 0 1px ${goldColors.primary}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={ArrowUpDown} boxSize={4} color={textColors.muted} />
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
value={sortType}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
bg={bgColors.item}
|
||||||
|
border={`1px solid ${borderColors.primary}`}
|
||||||
|
borderRadius="10px"
|
||||||
|
color={textColors.secondary}
|
||||||
|
w="120px"
|
||||||
|
_hover={{ borderColor: borderColors.gold }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: goldColors.primary,
|
||||||
|
boxShadow: `0 0 0 1px ${goldColors.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="count" style={{ background: "#1a1a2e" }}>
|
||||||
|
按数量
|
||||||
|
</option>
|
||||||
|
<option value="time" style={{ background: "#1a1a2e" }}>
|
||||||
|
按时间
|
||||||
|
</option>
|
||||||
|
<option value="name" style={{ background: "#1a1a2e" }}>
|
||||||
|
按名称
|
||||||
|
</option>
|
||||||
|
</Select>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SectorHeader.displayName = "SectorHeader";
|
||||||
|
|
||||||
|
export default SectorHeader;
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* 单个板块项组件
|
||||||
|
* 支持 accordion 和 card 两种显示模式
|
||||||
|
*/
|
||||||
|
import React, { memo, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Icon,
|
||||||
|
Flex,
|
||||||
|
Collapse,
|
||||||
|
Circle,
|
||||||
|
Wrap,
|
||||||
|
WrapItem,
|
||||||
|
Tag,
|
||||||
|
TagLabel,
|
||||||
|
Accordion,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionButton,
|
||||||
|
AccordionPanel,
|
||||||
|
AccordionIcon,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Star, Zap, ChevronDown, ChevronUp, Sparkles } from "lucide-react";
|
||||||
|
import type { SectorData, LimitUpStock } from "@/types/limitAnalyse";
|
||||||
|
import { formatLimitUpTime, getSectorColor } from "@/utils/limitAnalyseUtils";
|
||||||
|
import {
|
||||||
|
goldColors,
|
||||||
|
textColors,
|
||||||
|
bgColors,
|
||||||
|
marketColors,
|
||||||
|
SECTOR_COLORS,
|
||||||
|
} from "@/constants/limitAnalyseTheme";
|
||||||
|
import StockListItem from "./StockListItem";
|
||||||
|
|
||||||
|
/** 获取板块渐变色 */
|
||||||
|
function getSectorGradient(sector: string): string {
|
||||||
|
if (sector === "公告") return "linear(to-r, #D4AF37, #F4D03F)";
|
||||||
|
if (sector === "其他") return "linear(to-r, #6B7280, #9CA3AF)";
|
||||||
|
if (
|
||||||
|
sector.includes("AI") ||
|
||||||
|
sector.includes("人工智能") ||
|
||||||
|
sector.includes("芯片")
|
||||||
|
)
|
||||||
|
return "linear(to-r, #8B5CF6, #A78BFA)";
|
||||||
|
if (
|
||||||
|
sector.includes("锂电") ||
|
||||||
|
sector.includes("电池") ||
|
||||||
|
sector.includes("新能源")
|
||||||
|
)
|
||||||
|
return "linear(to-r, #10B981, #34D399)";
|
||||||
|
if (sector.includes("医药") || sector.includes("医疗"))
|
||||||
|
return "linear(to-r, #EC4899, #F472B6)";
|
||||||
|
if (sector.includes("金融") || sector.includes("银行"))
|
||||||
|
return "linear(to-r, #F59E0B, #FBBF24)";
|
||||||
|
if (sector.includes("军工") || sector.includes("航空"))
|
||||||
|
return "linear(to-r, #EF4444, #F87171)";
|
||||||
|
return "linear(to-r, #06B6D4, #22D3EE)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取板块单色 */
|
||||||
|
function getSectorSingleColor(sector: string): string {
|
||||||
|
if (sector === "公告") return "#D4AF37";
|
||||||
|
if (sector === "其他") return "#9CA3AF";
|
||||||
|
if (
|
||||||
|
sector.includes("AI") ||
|
||||||
|
sector.includes("人工智能") ||
|
||||||
|
sector.includes("芯片")
|
||||||
|
)
|
||||||
|
return "#8B5CF6";
|
||||||
|
if (
|
||||||
|
sector.includes("锂电") ||
|
||||||
|
sector.includes("电池") ||
|
||||||
|
sector.includes("新能源")
|
||||||
|
)
|
||||||
|
return "#10B981";
|
||||||
|
if (sector.includes("医药") || sector.includes("医疗")) return "#EC4899";
|
||||||
|
if (sector.includes("金融") || sector.includes("银行")) return "#F59E0B";
|
||||||
|
if (sector.includes("军工") || sector.includes("航空")) return "#EF4444";
|
||||||
|
return "#06B6D4";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectorItemProps {
|
||||||
|
/** 板块名称 */
|
||||||
|
sector: string;
|
||||||
|
/** 板块数据 */
|
||||||
|
data: SectorData;
|
||||||
|
/** 序号索引 */
|
||||||
|
index: number;
|
||||||
|
/** 是否展开 */
|
||||||
|
isExpanded: boolean;
|
||||||
|
/** 切换展开回调 */
|
||||||
|
onToggle: () => void;
|
||||||
|
/** 显示模式 */
|
||||||
|
displayMode?: "accordion" | "card";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个板块项组件
|
||||||
|
*/
|
||||||
|
const SectorItem = memo<SectorItemProps>(
|
||||||
|
({ sector, data, index, isExpanded, onToggle, displayMode = "card" }) => {
|
||||||
|
const stocks = data.stocks || [];
|
||||||
|
const leadingStock = stocks[0];
|
||||||
|
const sectorColor = getSectorSingleColor(sector);
|
||||||
|
|
||||||
|
const firstLimitTime = leadingStock
|
||||||
|
? formatLimitUpTime(leadingStock)
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
// 按时间排序的股票
|
||||||
|
const sortedStocks = useMemo(() => {
|
||||||
|
return [...stocks].sort((a, b) =>
|
||||||
|
(a.zt_time || "").localeCompare(b.zt_time || "")
|
||||||
|
);
|
||||||
|
}, [stocks]);
|
||||||
|
|
||||||
|
// Card 模式
|
||||||
|
if (displayMode === "card") {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={bgColors.item}
|
||||||
|
borderRadius="16px"
|
||||||
|
border={`1px solid ${
|
||||||
|
isExpanded ? sectorColor + "40" : "rgba(255, 255, 255, 0.06)"
|
||||||
|
}`}
|
||||||
|
overflow="hidden"
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ borderColor: sectorColor + "30" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={3}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={onToggle}
|
||||||
|
bg={isExpanded ? bgColors.hover : "transparent"}
|
||||||
|
_hover={{ bg: bgColors.hover }}
|
||||||
|
transition="background 0.2s"
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing={3} flex={1}>
|
||||||
|
{/* 序号徽章 */}
|
||||||
|
<Flex
|
||||||
|
w="32px"
|
||||||
|
h="32px"
|
||||||
|
borderRadius="10px"
|
||||||
|
bg={
|
||||||
|
index < 3
|
||||||
|
? `linear-gradient(135deg, ${sectorColor}, ${sectorColor}88)`
|
||||||
|
: "rgba(255, 255, 255, 0.1)"
|
||||||
|
}
|
||||||
|
color={index < 3 ? "white" : textColors.secondary}
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
boxShadow={index < 3 ? `0 2px 10px ${sectorColor}40` : "none"}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<VStack align="start" spacing={0} flex={1}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text fontWeight="bold" fontSize="md" color={textColors.primary}>
|
||||||
|
{sector}
|
||||||
|
</Text>
|
||||||
|
{sector === "公告" && (
|
||||||
|
<Icon as={Sparkles} boxSize={4} color={goldColors.primary} />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={3} fontSize="xs" color={textColors.muted}>
|
||||||
|
<Text>
|
||||||
|
<Text as="span" color={sectorColor} fontWeight="bold">
|
||||||
|
{data.count || stocks.length}
|
||||||
|
</Text>{" "}
|
||||||
|
只涨停
|
||||||
|
</Text>
|
||||||
|
<Text>首板 {firstLimitTime}</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing={1} px={3}>
|
||||||
|
<Icon as={Zap} boxSize={4} color={marketColors.up} />
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color={marketColors.up}>
|
||||||
|
{data.count || stocks.length}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing={4}>
|
||||||
|
{leadingStock && (
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text color={goldColors.light} fontWeight="500" fontSize="sm">
|
||||||
|
{leadingStock.sname}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
bg="rgba(239, 68, 68, 0.15)"
|
||||||
|
color={marketColors.up}
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
+{leadingStock.change_pct || "9.99"}%
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
as={isExpanded ? ChevronUp : ChevronDown}
|
||||||
|
boxSize={5}
|
||||||
|
color={textColors.muted}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 收起时的成分股标签预览 */}
|
||||||
|
{!isExpanded && stocks.length > 0 && (
|
||||||
|
<Wrap spacing={2} mt={2} ml="44px">
|
||||||
|
{stocks.slice(0, 7).map((stock, idx) => (
|
||||||
|
<WrapItem key={stock.scode || idx}>
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg="rgba(96, 165, 250, 0.1)"
|
||||||
|
color="#60a5fa"
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
<TagLabel>{stock.sname}</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
</WrapItem>
|
||||||
|
))}
|
||||||
|
{stocks.length > 7 && (
|
||||||
|
<WrapItem>
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg={bgColors.hover}
|
||||||
|
color={textColors.muted}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
<TagLabel>+{stocks.length - 7}</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
</WrapItem>
|
||||||
|
)}
|
||||||
|
</Wrap>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Body - 展开的个股明细 */}
|
||||||
|
<Collapse in={isExpanded} animateOpacity>
|
||||||
|
<Box borderTop="1px solid rgba(255, 255, 255, 0.06)" bg="rgba(0, 0, 0, 0.2)">
|
||||||
|
<VStack spacing={0} align="stretch">
|
||||||
|
{sortedStocks.map((stock, idx) => (
|
||||||
|
<StockListItem
|
||||||
|
key={stock.scode || idx}
|
||||||
|
stock={stock}
|
||||||
|
index={idx}
|
||||||
|
sectorColor={sectorColor}
|
||||||
|
displayMode="card"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accordion 模式 - 使用 Chakra Accordion
|
||||||
|
return (
|
||||||
|
<AccordionItem border="none" mb={2}>
|
||||||
|
<AccordionButton
|
||||||
|
bg={isExpanded ? bgColors.hover : bgColors.item}
|
||||||
|
borderRadius="14px"
|
||||||
|
border={`1px solid ${
|
||||||
|
isExpanded ? sectorColor + "40" : "rgba(255, 255, 255, 0.06)"
|
||||||
|
}`}
|
||||||
|
_hover={{ bg: bgColors.hover, borderColor: sectorColor + "30" }}
|
||||||
|
py={3}
|
||||||
|
px={4}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
<HStack flex={1} spacing={3}>
|
||||||
|
<Circle
|
||||||
|
size="32px"
|
||||||
|
bgGradient={getSectorGradient(sector)}
|
||||||
|
color="white"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
boxShadow={`0 2px 10px ${sectorColor}40`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Circle>
|
||||||
|
|
||||||
|
<VStack align="start" spacing={0} flex={1}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text fontWeight="bold" fontSize="md" color={textColors.primary}>
|
||||||
|
{sector}
|
||||||
|
</Text>
|
||||||
|
{sector === "公告" && (
|
||||||
|
<Icon as={Star} boxSize={4} color={goldColors.primary} />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={3} fontSize="xs" color={textColors.muted}>
|
||||||
|
<Text>
|
||||||
|
<Text as="span" color={sectorColor} fontWeight="bold">
|
||||||
|
{data.count}
|
||||||
|
</Text>{" "}
|
||||||
|
只涨停
|
||||||
|
</Text>
|
||||||
|
{leadingStock && <Text>首板 {firstLimitTime}</Text>}
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<AccordionIcon color={textColors.secondary} />
|
||||||
|
</AccordionButton>
|
||||||
|
|
||||||
|
<AccordionPanel pb={3} pt={2} px={2}>
|
||||||
|
<VStack align="stretch" spacing={2}>
|
||||||
|
{sortedStocks.map((stock, idx) => (
|
||||||
|
<StockListItem
|
||||||
|
key={stock.scode || idx}
|
||||||
|
stock={stock}
|
||||||
|
index={idx}
|
||||||
|
sectorColor={sectorColor}
|
||||||
|
displayMode="accordion"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SectorItem.displayName = "SectorItem";
|
||||||
|
|
||||||
|
export default SectorItem;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* 股票列表项组件
|
||||||
|
* 展开后显示的个股明细
|
||||||
|
*/
|
||||||
|
import React, { memo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Flex,
|
||||||
|
Link,
|
||||||
|
Icon,
|
||||||
|
Wrap,
|
||||||
|
WrapItem,
|
||||||
|
Tag,
|
||||||
|
TagLabel,
|
||||||
|
Tooltip,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Flame, Clock, ExternalLink } from "lucide-react";
|
||||||
|
import type { LimitUpStock } from "@/types/limitAnalyse";
|
||||||
|
import { getBoardStyle, formatLimitUpTime } from "@/utils/limitAnalyseUtils";
|
||||||
|
import {
|
||||||
|
goldColors,
|
||||||
|
textColors,
|
||||||
|
bgColors,
|
||||||
|
marketColors,
|
||||||
|
} from "@/constants/limitAnalyseTheme";
|
||||||
|
|
||||||
|
/** 获取板块颜色 */
|
||||||
|
function getSectorColor(sector: string): string {
|
||||||
|
if (sector === "公告") return "#D4AF37";
|
||||||
|
if (sector === "其他") return "#9CA3AF";
|
||||||
|
if (
|
||||||
|
sector.includes("AI") ||
|
||||||
|
sector.includes("人工智能") ||
|
||||||
|
sector.includes("芯片") ||
|
||||||
|
sector.includes("大模型")
|
||||||
|
)
|
||||||
|
return "#8B5CF6";
|
||||||
|
if (
|
||||||
|
sector.includes("锂电") ||
|
||||||
|
sector.includes("电池") ||
|
||||||
|
sector.includes("新能源")
|
||||||
|
)
|
||||||
|
return "#10B981";
|
||||||
|
if (
|
||||||
|
sector.includes("医药") ||
|
||||||
|
sector.includes("医疗") ||
|
||||||
|
sector.includes("创新药")
|
||||||
|
)
|
||||||
|
return "#EC4899";
|
||||||
|
if (sector.includes("金融") || sector.includes("银行")) return "#F59E0B";
|
||||||
|
if (sector.includes("军工") || sector.includes("航空")) return "#EF4444";
|
||||||
|
return "#06B6D4";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockListItemProps {
|
||||||
|
/** 股票数据 */
|
||||||
|
stock: LimitUpStock;
|
||||||
|
/** 序号 */
|
||||||
|
index: number;
|
||||||
|
/** 板块颜色 */
|
||||||
|
sectorColor?: string;
|
||||||
|
/** 显示模式 */
|
||||||
|
displayMode?: "accordion" | "card";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票列表项组件
|
||||||
|
*/
|
||||||
|
const StockListItem = memo<StockListItemProps>(
|
||||||
|
({ stock, index, sectorColor, displayMode = "card" }) => {
|
||||||
|
const boardHeight = parseInt(stock.continuous_days || "1") || 1;
|
||||||
|
const boardStyle = getBoardStyle(boardHeight);
|
||||||
|
const stockTime = formatLimitUpTime(stock);
|
||||||
|
const firstDate = stock.first_time ? stock.first_time.split(" ")[0] : null;
|
||||||
|
|
||||||
|
const handleLinkClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Accordion 模式的样式
|
||||||
|
if (displayMode === "accordion") {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
borderRadius="12px"
|
||||||
|
bg={bgColors.item}
|
||||||
|
border="1px solid rgba(255, 255, 255, 0.06)"
|
||||||
|
borderLeft="3px solid"
|
||||||
|
borderLeftColor={sectorColor}
|
||||||
|
_hover={{
|
||||||
|
bg: bgColors.hover,
|
||||||
|
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||||
|
}}
|
||||||
|
transition="all 0.15s"
|
||||||
|
>
|
||||||
|
{/* 第一行:名称+代码+连板+涨幅 */}
|
||||||
|
<HStack justify="space-between" mb={2}>
|
||||||
|
<HStack spacing={2} flex={1}>
|
||||||
|
<Link
|
||||||
|
href={`/company?scode=${stock.scode}`}
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
color={goldColors.light}
|
||||||
|
_hover={{ color: goldColors.primary, textDecoration: "underline" }}
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
{stock.sname}
|
||||||
|
<Icon as={ExternalLink} boxSize={3} ml={1} />
|
||||||
|
</Link>
|
||||||
|
<Text fontSize="xs" color={textColors.muted}>
|
||||||
|
{stock.scode}
|
||||||
|
</Text>
|
||||||
|
{stock.continuous_days && (
|
||||||
|
<Badge
|
||||||
|
fontSize="10px"
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="full"
|
||||||
|
bg={`${boardStyle.color}20`}
|
||||||
|
color={boardStyle.color}
|
||||||
|
border={`1px solid ${boardStyle.color}40`}
|
||||||
|
>
|
||||||
|
{stock.continuous_days}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{stock.change_pct && (
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color={marketColors.up}>
|
||||||
|
+{stock.change_pct}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 第二行:时间+板块标签 */}
|
||||||
|
<HStack justify="space-between" fontSize="xs">
|
||||||
|
<HStack spacing={3} color={textColors.muted}>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Icon as={Clock} boxSize={3} />
|
||||||
|
<Text>涨停 {stockTime}</Text>
|
||||||
|
</HStack>
|
||||||
|
{firstDate && <Text>首板 {firstDate}</Text>}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{stock.core_sectors && stock.core_sectors.length > 0 && (
|
||||||
|
<Wrap spacing={1} justify="flex-end">
|
||||||
|
{stock.core_sectors.slice(0, 3).map((s, i) => (
|
||||||
|
<WrapItem key={`${s}-${i}`}>
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg={`${getSectorColor(s)}15`}
|
||||||
|
color={getSectorColor(s)}
|
||||||
|
border={`1px solid ${getSectorColor(s)}30`}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<TagLabel fontSize="10px">{s}</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
</WrapItem>
|
||||||
|
))}
|
||||||
|
{stock.core_sectors.length > 3 && (
|
||||||
|
<WrapItem>
|
||||||
|
<Tooltip
|
||||||
|
label={stock.core_sectors.slice(3).join("、")}
|
||||||
|
placement="top"
|
||||||
|
bg={bgColors.card}
|
||||||
|
color={textColors.primary}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg={bgColors.hover}
|
||||||
|
color={textColors.muted}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<TagLabel fontSize="10px">
|
||||||
|
+{stock.core_sectors.length - 3}
|
||||||
|
</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</WrapItem>
|
||||||
|
)}
|
||||||
|
</Wrap>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card 模式的样式
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={4}
|
||||||
|
py={3}
|
||||||
|
borderBottom="1px solid rgba(255, 255, 255, 0.04)"
|
||||||
|
_hover={{ bg: "rgba(255, 255, 255, 0.02)" }}
|
||||||
|
transition="background 0.15s"
|
||||||
|
>
|
||||||
|
{/* 第一行:序号 + 名称 + 代码 + 连板标签 + 时间 + 涨幅 */}
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing={3} flex={1}>
|
||||||
|
<Text w="20px" fontSize="xs" color={textColors.muted} textAlign="center">
|
||||||
|
{index + 1}.
|
||||||
|
</Text>
|
||||||
|
<Link
|
||||||
|
href={`/company?scode=${stock.scode}`}
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="sm"
|
||||||
|
color={goldColors.light}
|
||||||
|
_hover={{ color: goldColors.primary, textDecoration: "underline" }}
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
{stock.sname}
|
||||||
|
<Icon as={ExternalLink} boxSize={3} ml={1} />
|
||||||
|
</Link>
|
||||||
|
<Text fontSize="xs" color={textColors.muted}>
|
||||||
|
{stock.scode}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
bg={boardStyle.bg}
|
||||||
|
color={boardStyle.color}
|
||||||
|
border={boardStyle.border}
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="md"
|
||||||
|
>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
{boardStyle.showFlame && <Icon as={Flame} boxSize={3} />}
|
||||||
|
<Text>{boardStyle.label}</Text>
|
||||||
|
</HStack>
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing={4}>
|
||||||
|
<HStack spacing={1} color={textColors.muted} fontSize="xs">
|
||||||
|
<Icon as={Clock} boxSize={3} />
|
||||||
|
<Text>{stockTime}</Text>
|
||||||
|
{firstDate && <Text>首板 {firstDate}</Text>}
|
||||||
|
</HStack>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={marketColors.up}
|
||||||
|
minW="60px"
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
+{stock.change_pct || "9.99"}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 第二行:所属板块标签 */}
|
||||||
|
{stock.core_sectors && stock.core_sectors.length > 0 && (
|
||||||
|
<Wrap spacing={1} mt={2} ml="28px">
|
||||||
|
{stock.core_sectors.slice(0, 4).map((s, i) => (
|
||||||
|
<WrapItem key={`${s}-${i}`}>
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg={`${getSectorColor(s)}15`}
|
||||||
|
color={getSectorColor(s)}
|
||||||
|
border={`1px solid ${getSectorColor(s)}30`}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<TagLabel fontSize="10px">{s}</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
</WrapItem>
|
||||||
|
))}
|
||||||
|
{stock.core_sectors.length > 4 && (
|
||||||
|
<WrapItem>
|
||||||
|
<Tooltip
|
||||||
|
label={stock.core_sectors.slice(4).join("、")}
|
||||||
|
placement="top"
|
||||||
|
bg={bgColors.card}
|
||||||
|
color={textColors.primary}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
size="sm"
|
||||||
|
bg={bgColors.hover}
|
||||||
|
color={textColors.muted}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<TagLabel fontSize="10px">
|
||||||
|
+{stock.core_sectors.length - 4}
|
||||||
|
</TagLabel>
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</WrapItem>
|
||||||
|
)}
|
||||||
|
</Wrap>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
StockListItem.displayName = "StockListItem";
|
||||||
|
|
||||||
|
export default StockListItem;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* UnifiedSectorCard 子组件导出
|
||||||
|
*/
|
||||||
|
export { default as SectorHeader } from "./SectorHeader";
|
||||||
|
export { default as SectorItem } from "./SectorItem";
|
||||||
|
export { default as StockListItem } from "./StockListItem";
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 板块搜索和排序 Hook
|
||||||
|
*/
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import type { SortedSectors, SectorSortType } from "@/types/limitAnalyse";
|
||||||
|
|
||||||
|
interface UseFilteredSectorsResult {
|
||||||
|
/** 搜索文本 */
|
||||||
|
searchText: string;
|
||||||
|
/** 设置搜索文本 */
|
||||||
|
setSearchText: (text: string) => void;
|
||||||
|
/** 排序类型 */
|
||||||
|
sortType: SectorSortType;
|
||||||
|
/** 设置排序类型 */
|
||||||
|
setSortType: (type: SectorSortType) => void;
|
||||||
|
/** 过滤排序后的板块 */
|
||||||
|
filteredSectors: SortedSectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 板块搜索和排序 Hook
|
||||||
|
*/
|
||||||
|
export function useFilteredSectors(
|
||||||
|
sortedSectors: SortedSectors
|
||||||
|
): UseFilteredSectorsResult {
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [sortType, setSortType] = useState<SectorSortType>("count");
|
||||||
|
|
||||||
|
const filteredSectors = useMemo(() => {
|
||||||
|
let result = [...sortedSectors];
|
||||||
|
|
||||||
|
// 搜索过滤
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const keyword = searchText.trim().toLowerCase();
|
||||||
|
result = result.filter(([sector, data]) => {
|
||||||
|
// 匹配板块名称
|
||||||
|
if (sector.toLowerCase().includes(keyword)) return true;
|
||||||
|
// 匹配股票名称或代码
|
||||||
|
return data.stocks?.some(
|
||||||
|
(stock) =>
|
||||||
|
stock.sname?.toLowerCase().includes(keyword) ||
|
||||||
|
stock.scode?.includes(keyword)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
if (sortType === "count") {
|
||||||
|
result.sort((a, b) => (b[1].count || 0) - (a[1].count || 0));
|
||||||
|
} else if (sortType === "time") {
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const timeA = a[1].stocks?.[0]?.zt_time || "";
|
||||||
|
const timeB = b[1].stocks?.[0]?.zt_time || "";
|
||||||
|
return timeA.localeCompare(timeB);
|
||||||
|
});
|
||||||
|
} else if (sortType === "name") {
|
||||||
|
result.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [sortedSectors, searchText, sortType]);
|
||||||
|
|
||||||
|
const handleSetSearchText = useCallback((text: string) => {
|
||||||
|
setSearchText(text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetSortType = useCallback((type: SectorSortType) => {
|
||||||
|
setSortType(type);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchText,
|
||||||
|
setSearchText: handleSetSearchText,
|
||||||
|
sortType,
|
||||||
|
setSortType: handleSetSortType,
|
||||||
|
filteredSectors,
|
||||||
|
};
|
||||||
|
}
|
||||||
170
src/views/LimitAnalyse/components/UnifiedSectorCard/index.tsx
Normal file
170
src/views/LimitAnalyse/components/UnifiedSectorCard/index.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* 统一板块卡片组件
|
||||||
|
* 整合 SectorDetails(手风琴模式)和 SmartSectorCard(卡片模式)
|
||||||
|
*
|
||||||
|
* displayMode:
|
||||||
|
* - 'accordion': 手风琴展开模式(原 SectorDetails)
|
||||||
|
* - 'card': 卡片收起/展开模式(原 SmartSectorCard)
|
||||||
|
*/
|
||||||
|
import React, { useState, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
Flex,
|
||||||
|
Icon,
|
||||||
|
Text,
|
||||||
|
Accordion,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Filter, Search } from "lucide-react";
|
||||||
|
import { css } from "@emotion/react";
|
||||||
|
import { GLASS_BLUR } from "@/constants/glassConfig";
|
||||||
|
import type { SortedSectors, UnifiedSectorCardProps } from "@/types/limitAnalyse";
|
||||||
|
import {
|
||||||
|
goldColors,
|
||||||
|
textColors,
|
||||||
|
bgColors,
|
||||||
|
borderColors,
|
||||||
|
shimmer,
|
||||||
|
glowPulse,
|
||||||
|
} from "@/constants/limitAnalyseTheme";
|
||||||
|
import { scrollbarStyles } from "@/styles/limitAnalyseStyles";
|
||||||
|
import { useFilteredSectors } from "./hooks/useFilteredSectors";
|
||||||
|
import { SectorHeader, SectorItem } from "./components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一板块卡片主组件
|
||||||
|
*/
|
||||||
|
const UnifiedSectorCard: React.FC<UnifiedSectorCardProps> = ({
|
||||||
|
sortedSectors = [],
|
||||||
|
totalStocks = 0,
|
||||||
|
displayMode = "card",
|
||||||
|
}) => {
|
||||||
|
// 用于追踪展开的板块
|
||||||
|
const [expandedSectors, setExpandedSectors] = useState<string[]>([]);
|
||||||
|
// 用于 Accordion 模式的索引追踪
|
||||||
|
const [accordionIndexes, setAccordionIndexes] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// 使用 hook 处理搜索和排序
|
||||||
|
const {
|
||||||
|
searchText,
|
||||||
|
setSearchText,
|
||||||
|
sortType,
|
||||||
|
setSortType,
|
||||||
|
filteredSectors,
|
||||||
|
} = useFilteredSectors(sortedSectors);
|
||||||
|
|
||||||
|
// 切换板块展开状态(Card 模式)
|
||||||
|
const toggleSector = useCallback((sectorName: string) => {
|
||||||
|
setExpandedSectors((prev) =>
|
||||||
|
prev.includes(sectorName)
|
||||||
|
? prev.filter((s) => s !== sectorName)
|
||||||
|
: [...prev, sectorName]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理 Accordion 变更(Accordion 模式)
|
||||||
|
const handleAccordionChange = useCallback((indexes: number[]) => {
|
||||||
|
setAccordionIndexes(indexes);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 标题和副标题根据模式不同
|
||||||
|
const title = displayMode === "accordion" ? "板块详情" : "板块异动详情";
|
||||||
|
const subtitle =
|
||||||
|
displayMode === "accordion"
|
||||||
|
? "涨停板块分布分析"
|
||||||
|
: "点击展开查看个股明细";
|
||||||
|
|
||||||
|
// 空数据状态图标
|
||||||
|
const EmptyIcon = displayMode === "accordion" ? Filter : Search;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={bgColors.card}
|
||||||
|
backdropFilter={`${GLASS_BLUR.lg} saturate(180%)`}
|
||||||
|
borderRadius="24px"
|
||||||
|
border={`1px solid ${borderColors.gold}`}
|
||||||
|
overflow="hidden"
|
||||||
|
position="relative"
|
||||||
|
css={css`
|
||||||
|
animation: ${glowPulse} 4s ease-in-out infinite;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* 顶部金色装饰条 */}
|
||||||
|
<Box
|
||||||
|
h="3px"
|
||||||
|
bgGradient="linear(to-r, #B8860B, #D4AF37, #F4D03F, #D4AF37, #B8860B)"
|
||||||
|
backgroundSize="200% 100%"
|
||||||
|
css={css`
|
||||||
|
animation: ${shimmer} 3s linear infinite;
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<SectorHeader
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
sectorCount={filteredSectors.length}
|
||||||
|
totalStocks={totalStocks}
|
||||||
|
searchText={searchText}
|
||||||
|
onSearchChange={setSearchText}
|
||||||
|
sortType={sortType}
|
||||||
|
onSortChange={setSortType}
|
||||||
|
displayMode={displayMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 板块列表 */}
|
||||||
|
<Box
|
||||||
|
maxH={displayMode === "accordion" ? "600px" : "700px"}
|
||||||
|
overflowY="auto"
|
||||||
|
px={4}
|
||||||
|
py={3}
|
||||||
|
sx={scrollbarStyles}
|
||||||
|
>
|
||||||
|
{filteredSectors.length === 0 ? (
|
||||||
|
<Flex justify="center" align="center" py={10}>
|
||||||
|
<VStack spacing={2}>
|
||||||
|
<Icon as={EmptyIcon} boxSize={8} color={textColors.muted} />
|
||||||
|
<Text color={textColors.muted}>未找到匹配的板块</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
) : displayMode === "accordion" ? (
|
||||||
|
// Accordion 模式
|
||||||
|
<Accordion
|
||||||
|
allowMultiple
|
||||||
|
index={accordionIndexes}
|
||||||
|
onChange={handleAccordionChange}
|
||||||
|
>
|
||||||
|
{filteredSectors.map(([sector, data], index) => (
|
||||||
|
<SectorItem
|
||||||
|
key={`${sector}-${index}`}
|
||||||
|
sector={sector}
|
||||||
|
data={data}
|
||||||
|
index={index}
|
||||||
|
isExpanded={accordionIndexes.includes(index)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
displayMode="accordion"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
) : (
|
||||||
|
// Card 模式
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
{filteredSectors.map(([sector, data], index) => (
|
||||||
|
<SectorItem
|
||||||
|
key={`${sector}-${index}`}
|
||||||
|
sector={sector}
|
||||||
|
data={data}
|
||||||
|
index={index}
|
||||||
|
isExpanded={expandedSectors.includes(sector)}
|
||||||
|
onToggle={() => toggleSector(sector)}
|
||||||
|
displayMode="card"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnifiedSectorCard;
|
||||||
Reference in New Issue
Block a user