添加重构后的组件目录

This commit is contained in:
zdl
2026-01-05 14:55:39 +08:00
parent d714f7d09f
commit bd15c9775c
21 changed files with 2689 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
/**
* HighPositionSidebar 子组件导出
*/
export { default as HighPositionItem } from "./HighPositionItem";
export { default as RiskVane } from "./RiskVane";
export { default as RiskWarning } from "./RiskWarning";

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
/**
* HighPositionStocks 子组件导出
*/
export { default as StatisticsCard } from "./StatisticsCard";
export { default as RiskBadges } from "./RiskBadges";
export { default as HighPositionTable } from "./HighPositionTable";

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

View File

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

View File

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

View File

@@ -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"
>
# &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</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"
>
&nbsp;&nbsp;
</Text>
</Flex>
</Box>
);
});
TableHeader.displayName = "TableHeader";
export default TableHeader;

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
/**
* UnifiedSectorCard 子组件导出
*/
export { default as SectorHeader } from "./SectorHeader";
export { default as SectorItem } from "./SectorItem";
export { default as StockListItem } from "./StockListItem";

View File

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

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