refactor(HeroPanel): 提取 DetailModal 相关组件

- 主弹窗 DetailModal 使用 Hook 管理状态
- ZTSectorView/ZTStockListView 使用 memo 优化
- EventsTabView 添加空状态处理
- RelatedEventsModal 涨停归因详情
- SectorStocksModal 板块股票详情

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2026-01-13 19:00:48 +08:00
parent e070df5d62
commit 2948f14904
7 changed files with 1954 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
// 未来事件视图组件
// 展示选定日期的未来事件列表
import React, { memo } from "react";
import { Table, Typography } from "antd";
import { CalendarOutlined } from "@ant-design/icons";
const { Text: AntText } = Typography;
/**
* 未来事件视图
* @param {Array} events - 事件列表
* @param {Array} columns - 表格列配置
*/
const EventsTabView = memo(({ events, columns }) => {
// 无数据时的空状态
if (!events?.length) {
return (
<div
style={{
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<CalendarOutlined style={{ fontSize: 48, color: "#666" }} />
<AntText type="secondary" style={{ fontSize: 16 }}>
暂无事件数据
</AntText>
</div>
</div>
);
}
return (
<Table
dataSource={events}
columns={columns}
rowKey="id"
size="small"
pagination={false}
scroll={{ x: 900, y: 420 }}
/>
);
});
EventsTabView.displayName = "EventsTabView";
export default EventsTabView;

View File

@@ -0,0 +1,220 @@
// HeroPanel - 关联事件弹窗(涨停归因详情)
// 使用 Ant Design Modal 保持与现有代码风格一致
import React from "react";
import { Modal as AntModal, Tag, ConfigProvider, theme } from "antd";
import { FileText } from "lucide-react";
import { GLASS_BLUR } from "@/constants/glassConfig";
/**
* 获取相关度颜色
*/
const getRelevanceColor = (score) => {
if (score >= 80) return "#10B981";
if (score >= 60) return "#F59E0B";
return "#6B7280";
};
/**
* 关联事件弹窗 - 涨停归因详情
*/
const RelatedEventsModal = ({
visible,
onClose,
sectorName = "",
events = [],
count = 0,
}) => {
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: { colorBgElevated: "rgba(15,15,30,0.98)" },
}}
>
<AntModal
open={visible}
onCancel={onClose}
footer={null}
width={700}
centered
title={
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
padding: "8px",
background: "rgba(96,165,250,0.15)",
borderRadius: "8px",
border: "1px solid rgba(96,165,250,0.3)",
}}
>
<FileText size={18} color="#60A5FA" />
</div>
<div>
<div style={{ fontSize: "18px", fontWeight: "bold", color: "#60A5FA" }}>
{sectorName} - 涨停归因
</div>
<div style={{ display: "flex", gap: "12px", marginTop: "4px" }}>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
涨停 <span style={{ color: "#EF4444", fontWeight: "bold" }}>{count}</span>
</span>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
关联事件 <span style={{ color: "#60A5FA", fontWeight: "bold" }}>{events?.length || 0}</span>
</span>
</div>
</div>
</div>
}
styles={{
header: {
background: "rgba(25,25,50,0.98)",
borderBottom: "1px solid rgba(96,165,250,0.2)",
padding: "16px 24px",
},
body: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
padding: "16px 24px",
maxHeight: "65vh",
overflowY: "auto",
},
content: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
borderRadius: "16px",
border: "1px solid rgba(96,165,250,0.3)",
},
mask: { background: "rgba(0,0,0,0.7)", backdropFilter: GLASS_BLUR.sm },
}}
>
{events?.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{events.map((event, idx) => {
const relevanceColor = getRelevanceColor(event.relevance_score || 0);
return (
<div
key={event.event_id || idx}
style={{
padding: "16px",
background: "rgba(30,30,50,0.8)",
borderRadius: "12px",
border: "1px solid rgba(255,255,255,0.06)",
cursor: "pointer",
transition: "all 0.2s",
}}
onClick={() => {
window.open(`/community?event_id=${event.event_id}`, "_blank");
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(40,40,70,0.9)";
e.currentTarget.style.borderColor = "rgba(96,165,250,0.3)";
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(30,30,50,0.8)";
e.currentTarget.style.borderColor = "rgba(255,255,255,0.06)";
e.currentTarget.style.transform = "translateY(0)";
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* 标题 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ display: "flex", gap: "8px", flex: 1 }}>
<FileText size={16} color="#60A5FA" style={{ flexShrink: 0 }} />
<span
style={{
fontSize: "14px",
fontWeight: "600",
color: "#E0E0E0",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{event.title}
</span>
</div>
<span
style={{
background: `${relevanceColor}20`,
color: relevanceColor,
fontSize: "12px",
padding: "2px 8px",
borderRadius: "6px",
flexShrink: 0,
}}
>
相关度 {event.relevance_score || 0}
</span>
</div>
{/* 相关原因 */}
{event.relevance_reason && (
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.6)", lineHeight: "1.6" }}>
{event.relevance_reason}
</span>
)}
{/* 匹配概念 */}
{event.matched_concepts?.length > 0 && (
<div>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.4)", marginBottom: "6px", display: "block" }}>
匹配概念
</span>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{event.matched_concepts.slice(0, 6).map((concept, i) => (
<Tag
key={i}
style={{
fontSize: "10px",
margin: "2px",
background: "rgba(139, 92, 246, 0.15)",
border: "none",
color: "#A78BFA",
borderRadius: "4px",
padding: "2px 8px",
}}
>
{concept}
</Tag>
))}
{event.matched_concepts.length > 6 && (
<Tag
style={{
fontSize: "10px",
margin: "2px",
background: "rgba(255,255,255,0.1)",
border: "none",
color: "#888",
borderRadius: "4px",
padding: "2px 8px",
}}
>
+{event.matched_concepts.length - 6}
</Tag>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div
style={{
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: "rgba(255,255,255,0.5)" }}>暂无关联事件</span>
</div>
)}
</AntModal>
</ConfigProvider>
);
};
export default RelatedEventsModal;

View File

@@ -0,0 +1,373 @@
// HeroPanel - 板块股票弹窗
// 使用 Ant Design Modal 保持与现有代码风格一致
import React from "react";
import { Modal as AntModal, Table, Tag, Button, Typography, ConfigProvider, theme } from "antd";
import { TagsOutlined, LineChartOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
import { GLASS_BLUR } from "@/constants/glassConfig";
const { Text: AntText } = Typography;
/**
* 获取连板天数样式
*/
const getDaysStyle = (days) => {
if (days >= 5)
return {
bg: "linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)",
text: "#fff",
};
if (days >= 3)
return {
bg: "linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)",
text: "#fff",
};
if (days >= 2)
return {
bg: "linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)",
text: "#fff",
};
return { bg: "rgba(255,255,255,0.1)", text: "#888" };
};
/**
* 获取涨停时间样式
*/
const getTimeStyle = (time) => {
if (time <= "09:30:00") return { bg: "#ff4d4f", text: "#fff" };
if (time <= "09:35:00") return { bg: "#fa541c", text: "#fff" };
if (time <= "10:00:00") return { bg: "#fa8c16", text: "#fff" };
return { bg: "rgba(255,255,255,0.1)", text: "#888" };
};
/**
* 板块股票弹窗
*/
const SectorStocksModal = ({
visible,
onClose,
sectorInfo,
onShowKline,
onAddToWatchlist,
isStockInWatchlist,
}) => {
if (!sectorInfo) return null;
const { name, count, stocks = [] } = sectorInfo;
// 连板统计
const stats = { 首板: 0, "2连板": 0, "3连板": 0, "4连板+": 0 };
stocks.forEach((s) => {
const days = s._continuousDays || 1;
if (days === 1) stats["首板"]++;
else if (days === 2) stats["2连板"]++;
else if (days === 3) stats["3连板"]++;
else stats["4连板+"]++;
});
// 表格列定义
const columns = [
{
title: "股票",
key: "stock",
width: 130,
render: (_, record) => (
<div style={{ display: "flex", flexDirection: "column" }}>
<a
href={`https://valuefrontier.cn/company?scode=${record.scode}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#FFD700", fontWeight: "bold", fontSize: "14px" }}
>
{record.sname}
</a>
<AntText style={{ color: "#60A5FA", fontSize: "12px" }}>
{record.scode}
</AntText>
</div>
),
},
{
title: "连板",
dataIndex: "continuous_days",
key: "continuous",
width: 90,
align: "center",
render: (text, record) => {
const days = record._continuousDays || 1;
const style = getDaysStyle(days);
return (
<span
style={{
padding: "4px 12px",
borderRadius: "6px",
background: style.bg,
fontSize: "13px",
fontWeight: "bold",
color: style.text,
display: "inline-block",
}}
>
{text || "首板"}
</span>
);
},
},
{
title: "涨停时间",
dataIndex: "formatted_time",
key: "time",
width: 90,
align: "center",
render: (time) => {
const style = getTimeStyle(time || "15:00:00");
return (
<span
style={{
padding: "2px 8px",
borderRadius: "6px",
background: style.bg,
fontSize: "12px",
color: style.text,
}}
>
{time?.substring(0, 5) || "-"}
</span>
);
},
},
{
title: "核心板块",
dataIndex: "core_sectors",
key: "sectors",
render: (sectors) => (
<div style={{ display: "flex", gap: "4px", flexWrap: "wrap" }}>
{(sectors || []).slice(0, 2).map((sector, idx) => (
<Tag
key={idx}
style={{
margin: "2px",
background: "rgba(255,215,0,0.1)",
border: "1px solid rgba(255,215,0,0.2)",
borderRadius: "4px",
color: "#D4A84B",
fontSize: "11px",
}}
>
{sector}
</Tag>
))}
</div>
),
},
{
title: "K线图",
key: "kline",
width: 80,
align: "center",
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => onShowKline(record)}
style={{
background: "linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%)",
border: "none",
borderRadius: "6px",
fontSize: "12px",
}}
>
查看
</Button>
),
},
{
title: "操作",
key: "action",
width: 90,
align: "center",
render: (_, record) => {
const code = record.scode;
const inWatchlist = isStockInWatchlist(code);
return (
<Button
type={inWatchlist ? "primary" : "default"}
size="small"
icon={inWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={() => onAddToWatchlist({ code, name: record.sname })}
disabled={inWatchlist}
style={
inWatchlist
? {
background: "linear-gradient(135deg, #faad14 0%, #fa8c16 100%)",
border: "none",
borderRadius: "6px",
fontSize: "12px",
}
: {
background: "rgba(255,255,255,0.1)",
border: "1px solid rgba(255,215,0,0.3)",
borderRadius: "6px",
color: "#FFD700",
fontSize: "12px",
}
}
>
{inWatchlist ? "已添加" : "加自选"}
</Button>
);
},
},
];
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: { colorBgElevated: "rgba(15,15,30,0.98)" },
}}
>
<AntModal
open={visible}
onCancel={onClose}
footer={null}
width={900}
centered
title={
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
padding: "8px",
background: "rgba(255,215,0,0.15)",
borderRadius: "8px",
border: "1px solid rgba(255,215,0,0.3)",
}}
>
<TagsOutlined style={{ color: "#FFD700", fontSize: "18px" }} />
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontSize: "18px", fontWeight: "bold", color: "#FFD700" }}>
{name}
</span>
<span
style={{
fontSize: "12px",
padding: "2px 8px",
background: "rgba(255,77,79,0.15)",
color: "#ff4d4f",
borderRadius: "12px",
}}
>
{count} 只涨停
</span>
</div>
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
按连板天数降序排列
</div>
</div>
</div>
}
styles={{
header: {
background: "rgba(25,25,50,0.98)",
borderBottom: "1px solid rgba(255,215,0,0.2)",
padding: "16px 24px",
},
body: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
padding: "16px 24px",
maxHeight: "70vh",
overflowY: "auto",
},
content: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
borderRadius: "16px",
border: "1px solid rgba(255,215,0,0.2)",
},
mask: { background: "rgba(0,0,0,0.7)", backdropFilter: GLASS_BLUR.sm },
}}
>
{stocks.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* 快速统计 */}
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
{Object.entries(stats).map(
([key, value]) =>
value > 0 && (
<span
key={key}
style={{
padding: "4px 12px",
borderRadius: "12px",
fontSize: "14px",
background:
key === "4连板+"
? "rgba(255,77,79,0.15)"
: key === "3连板"
? "rgba(250,84,28,0.15)"
: key === "2连板"
? "rgba(250,140,22,0.15)"
: "rgba(255,255,255,0.05)",
border: `1px solid ${
key === "4连板+"
? "rgba(255,77,79,0.3)"
: key === "3连板"
? "rgba(250,84,28,0.3)"
: key === "2连板"
? "rgba(250,140,22,0.3)"
: "rgba(255,255,255,0.1)"
}`,
color:
key === "4连板+"
? "#ff4d4f"
: key === "3连板"
? "#fa541c"
: key === "2连板"
? "#fa8c16"
: "#888",
}}
>
{key}: <strong>{value}</strong>
</span>
)
)}
</div>
{/* 股票列表 */}
<div
style={{
borderRadius: "12px",
border: "1px solid rgba(255,215,0,0.15)",
overflow: "hidden",
}}
className="sector-stocks-table-wrapper"
>
<Table
dataSource={stocks}
columns={columns}
rowKey="scode"
size="small"
pagination={false}
scroll={{ x: 650, y: 450 }}
/>
</div>
</div>
) : (
<div
style={{
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: "rgba(255,255,255,0.5)" }}>暂无股票数据</span>
</div>
)}
</AntModal>
</ConfigProvider>
);
};
export default SectorStocksModal;

View File

@@ -0,0 +1,39 @@
// 涨停板块视图组件
// 展示按板块分组的涨停数据表格
import React, { memo } from "react";
import { Table } from "antd";
/**
* 涨停板块视图
* @param {Array} sectorList - 板块列表数据
* @param {Array} columns - 表格列配置
*/
const ZTSectorView = memo(({ sectorList, columns }) => {
if (!sectorList?.length) {
return null;
}
return (
<div
style={{
borderRadius: "12px",
border: "1px solid rgba(255,215,0,0.15)",
overflow: "hidden",
}}
>
<Table
dataSource={sectorList}
columns={columns}
rowKey="name"
size="middle"
pagination={false}
scroll={{ y: 380 }}
/>
</div>
);
});
ZTSectorView.displayName = "ZTSectorView";
export default ZTSectorView;

View File

@@ -0,0 +1,134 @@
// 涨停个股视图组件
// 展示涨停个股列表,支持按板块筛选
import React, { memo } from "react";
import { Table } from "antd";
/**
* 涨停个股视图
* @param {Array} stockList - 完整股票列表
* @param {Array} filteredStockList - 筛选后的股票列表
* @param {Array} sectorList - 板块列表(用于筛选器)
* @param {Array} columns - 表格列配置
* @param {string|null} selectedSectorFilter - 当前选中的板块筛选
* @param {Function} onSectorFilterChange - 筛选变化回调
*/
const ZTStockListView = memo(({
stockList,
filteredStockList,
sectorList,
columns,
selectedSectorFilter,
onSectorFilterChange,
}) => {
if (!stockList?.length) {
return null;
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* 板块筛选器 */}
<div>
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginBottom: "8px" }}>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
板块筛选
</span>
<button
style={{
padding: "4px 12px",
borderRadius: "9999px",
background: !selectedSectorFilter ? "rgba(255,215,0,0.2)" : "rgba(255,255,255,0.05)",
border: !selectedSectorFilter ? "1px solid rgba(255,215,0,0.4)" : "1px solid rgba(255,255,255,0.1)",
color: !selectedSectorFilter ? "#FFD700" : "#888",
fontSize: "12px",
transition: "all 0.2s",
cursor: "pointer",
}}
onClick={() => onSectorFilterChange(null)}
>
全部 ({stockList.length})
</button>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{sectorList.slice(0, 10).map((sector) => (
<button
key={sector.name}
style={{
padding: "4px 10px",
borderRadius: "9999px",
background: selectedSectorFilter === sector.name ? "rgba(59,130,246,0.2)" : "rgba(255,255,255,0.03)",
border: selectedSectorFilter === sector.name ? "1px solid rgba(59,130,246,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: selectedSectorFilter === sector.name ? "#60A5FA" : "#888",
fontSize: "12px",
transition: "all 0.2s",
cursor: "pointer",
}}
onClick={() => onSectorFilterChange(selectedSectorFilter === sector.name ? null : sector.name)}
>
{sector.name} ({sector.count})
</button>
))}
</div>
</div>
{/* 筛选结果提示 */}
{selectedSectorFilter && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
background: "rgba(59,130,246,0.1)",
borderRadius: "8px",
border: "1px solid rgba(59,130,246,0.2)",
}}
>
<span style={{ color: "#60A5FA", fontSize: "13px" }}>
{selectedSectorFilter}
</span>
<span style={{ color: "rgba(255,255,255,0.5)", fontSize: "12px" }}>
{filteredStockList.length} 只涨停
</span>
<button
style={{
marginLeft: "auto",
padding: "2px 8px",
background: "transparent",
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: "4px",
color: "#888",
fontSize: "12px",
cursor: "pointer",
}}
onClick={() => onSectorFilterChange(null)}
>
清除筛选
</button>
</div>
)}
{/* 股票表格 */}
<div
style={{
borderRadius: "12px",
border: "1px solid rgba(59,130,246,0.15)",
overflow: "hidden",
}}
>
<Table
dataSource={filteredStockList}
columns={columns}
rowKey="scode"
size="small"
pagination={false}
scroll={{ y: 320 }}
/>
</div>
</div>
);
});
ZTStockListView.displayName = "ZTStockListView";
export default ZTStockListView;

View File

@@ -0,0 +1,7 @@
// HeroPanel - DetailModal 子组件导出
export { default as DetailModal } from "./DetailModal";
export { default as RelatedEventsModal } from "./RelatedEventsModal";
export { default as SectorStocksModal } from "./SectorStocksModal";
export { default as ZTSectorView } from "./ZTSectorView";
export { default as ZTStockListView } from "./ZTStockListView";
export { default as EventsTabView } from "./EventsTabView";