添加重构后的组件目录
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