Compare commits

..

15 Commits

Author SHA1 Message Date
zdl
e2f9f3278f refactor(ForecastReport): 3列布局 + 移除标题
- 移除盈利预测报表标题和刷新按钮
- 3个图表改为3列等宽布局
- 统一图表高度使用 CHART_HEIGHT
- 简化标题文字

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:40:34 +08:00
zdl
2d03c88f43 style(DynamicTracking): 应用黑金主题
- NewsEventsTab: 添加黑金主题配色系统
- ForecastPanel: 业绩预告面板黑金样式
- NewsPanel: 切换 blackGold 主题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:29 +08:00
zdl
515b538c84 refactor(ForecastReport): 合并营收/利润趋势与增长率图表
- 新增 IncomeProfitGrowthChart 合并组件
- 柱状图显示营业收入(左Y轴)
- 折线图显示净利润(左Y轴,渐变填充)
- 虚线显示增长率(右Y轴,红涨绿跌)
- 布局调整:合并图表独占一行,EPS/PE-PEG 两列

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:24 +08:00
zdl
b52b54347d fix(mock): 修复事件数据和 API 返回格式
- events.js: 增强搜索支持股票名称/代码,修复字段名
- event.js: 返回结构调整为 { data, pagination }

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:20 +08:00
zdl
4954373b5b style(ComparisonAnalysis): 应用黑金主题样式
- 图表配置:金色标题、深色 tooltip、金色坐标轴
- 净利润折线改为金色渐变填充
- 营收柱状图首个柱子使用金色
- 组件容器:透明背景 + 金色边框
- 移除外部重复标题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:35:14 +08:00
zdl
66cd6c3a29 fix(mock): 修复 periodComparison 数据结构
- 将 periodComparison 从对象格式改为数组格式
- 匹配 ComparisonAnalysis 组件期望的数据结构
- 修复"盈利与利润趋势"图表无法显示的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:33:07 +08:00
zdl
ba99f55b16 refactor(ForecastReport): 迁移至 TypeScript 2025-12-16 20:28:58 +08:00
zdl
2f69f83d16 feat(mock): 添加业绩预告 mock 数据
- 新增 /api/stock/:stockCode/forecast handler
 - 支持动态跟踪下的业绩预告面板
2025-12-16 20:27:43 +08:00
zdl
3bd48e1ddd refactor(StockQuoteCard): 拆分为原子组件
- 新增 theme.ts 黑金主题常量
  - 新增 formatters.ts 格式化工具函数
  - 拆分 PriceDisplay/SecondaryQuote/KeyMetrics/MainForceInfo/CompanyInfo/StockHeader
  - 主组件从 414 行简化为 150 行
  - 提高可维护性和复用性
2025-12-16 20:24:01 +08:00
zdl
84914b3cca fix(FinancialPanorama): 恢复盈利与利润趋势图表
- 重新引入 ComparisonAnalysis 组件
- 在财务全景面板下方显示营收与利润趋势柱状图
- 修复之前重构时遗漏的功能模块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:22:14 +08:00
zdl
da455946a3 style(MainBusinessAnalysis): 优化主营业务模块 UI
- 饼图配色改为黑金主题(金色系渐变)
- 修复表格固定列 hover 时背景色为白色的问题
- 统一表格单元格背景色为深色 #1A202C

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:20:15 +08:00
zdl
e734319ec4 refactor(FinancialPanorama): 使用 FinancialOverviewPanel 替换原头部组件
- 移除 StockInfoHeader 和 KeyMetricsOverview
- 使用新的三模块面板组件
- ROE 去重,布局统一

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:17:19 +08:00
zdl
faf2446203 feat(FinancialPanorama): 新增 FinancialOverviewPanel 三模块布局
- 复用 MetricCard 组件构建三列布局
- 成长能力:利润增长、营收增长、预增标签
- 盈利与回报:ROE、净利率、毛利率
- 风险与运营:资产负债率、流动比率、研发费用率

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:17:08 +08:00
zdl
83b24b6d54 style(MainBusinessAnalysis): 优化历史对比表格布局
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:15:57 +08:00
zdl
ab7164681a feat(StockQuoteCard): 新增每股收益(EPS)显示
- Mock 数据添加 eps、pb、主力动态等指标
  - StockQuoteCard 显示 EPS 数据
  - useStockQuote 支持 eps 字段转换
  - StockInfoHeader 移除重复的 EPS 显示
2025-12-16 20:08:35 +08:00
38 changed files with 2252 additions and 724 deletions

View File

@@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) {
e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) ||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) ||
// 搜索 related_stocks 中的股票名称和代码
(e.related_stocks && e.related_stocks.some(stock =>
(stock.stock_name && stock.stock_name.toLowerCase().includes(query)) ||
(stock.stock_code && stock.stock_code.toLowerCase().includes(query))
)) ||
// 搜索行业
(e.industry && e.industry.toLowerCase().includes(query))
);
// 如果搜索结果为空,返回所有事件(宽松模式)
if (filteredEvents.length === 0) {
filteredEvents = allEvents;
}
}
// 行业筛选
@@ -1042,7 +1054,7 @@ function generateTransmissionChain(industry, index) {
let nodeName;
if (nodeType === 'company' && industryStock) {
nodeName = industryStock.name;
nodeName = industryStock.stock_name;
} else if (nodeType === 'industry') {
nodeName = `${industry}产业`;
} else if (nodeType === 'policy') {
@@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const stock = industryStocks[j % industryStocks.length];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.name,
stock_name: stock.stock_name,
relation_desc: relationDescriptions[j % relationDescriptions.length]
});
}
@@ -1145,7 +1157,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
relatedStocks.push({
stock_code: randomStock.stock_code,
stock_name: randomStock.name,
stock_name: randomStock.stock_name,
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
});
}

View File

@@ -366,35 +366,50 @@ export const generateFinancialData = (stockCode) => {
}
],
// 期间对比
periodComparison: {
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
metrics: [
{
name: '营业收入',
unit: '百万元',
values: [41500, 40800, 40200, 40850],
yoy: [8.2, 7.8, 8.5, 9.2]
},
{
name: '净利润',
unit: '百万元',
values: [13420, 13180, 13050, 13210],
yoy: [12.5, 11.2, 10.8, 12.3]
},
{
name: 'ROE',
unit: '%',
values: [16.23, 15.98, 15.75, 16.02],
yoy: [1.2, 0.8, 0.5, 1.0]
},
{
name: 'EPS',
unit: '元',
values: [0.69, 0.68, 0.67, 0.68],
yoy: [12.3, 11.5, 10.5, 12.0]
// 期间对比 - 营收与利润趋势数据
periodComparison: [
{
period: '2024-09-30',
performance: {
revenue: 41500000000, // 415亿
net_profit: 13420000000 // 134.2亿
}
]
}
},
{
period: '2024-06-30',
performance: {
revenue: 40800000000, // 408亿
net_profit: 13180000000 // 131.8亿
}
},
{
period: '2024-03-31',
performance: {
revenue: 40200000000, // 402亿
net_profit: 13050000000 // 130.5亿
}
},
{
period: '2023-12-31',
performance: {
revenue: 40850000000, // 408.5亿
net_profit: 13210000000 // 132.1亿
}
},
{
period: '2023-09-30',
performance: {
revenue: 38500000000, // 385亿
net_profit: 11920000000 // 119.2亿
}
},
{
period: '2023-06-30',
performance: {
revenue: 37800000000, // 378亿
net_profit: 11850000000 // 118.5亿
}
}
]
};
};

View File

@@ -119,9 +119,12 @@ export const eventHandlers = [
try {
const result = generateMockEvents(params);
// 返回格式兼容 NewsPanel 期望的结构
// NewsPanel 期望: { success, data: [], pagination: {} }
return HttpResponse.json({
success: true,
data: result,
data: result.events, // 事件数组
pagination: result.pagination, // 分页信息
message: '获取成功'
});
} catch (error) {
@@ -135,16 +138,14 @@ export const eventHandlers = [
{
success: false,
error: '获取事件列表失败',
data: {
events: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0, // ← 对齐后端字段名
has_prev: false, // ← 对齐后端
has_next: false // ← 对齐后端
}
data: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_prev: false,
has_next: false
}
},
{ status: 500 }

View File

@@ -341,6 +341,68 @@ export const stockHandlers = [
}
}),
// 获取股票业绩预告
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
await delay(200);
const { stockCode } = params;
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
// 生成股票列表用于查找名称
const stockList = generateStockList();
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
const stockName = stockInfo?.name || `股票${stockCode}`;
// 业绩预告类型列表
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
// 生成业绩预告数据
const forecasts = [
{
forecast_type: '预增',
report_date: '2024年年报',
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元同比增长10%至17%。`,
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
change_range: {
lower: 10,
upper: 17
},
publish_date: '2024-10-15'
},
{
forecast_type: '略增',
report_date: '2024年三季报',
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元同比增长5%至12%。`,
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
change_range: {
lower: 5,
upper: 12
},
publish_date: '2024-07-12'
},
{
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
report_date: '2024年中报',
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
change_range: {
lower: 3,
upper: 8
},
publish_date: '2024-04-20'
}
];
return HttpResponse.json({
success: true,
data: {
stock_code: stockCode,
stock_name: stockName,
forecasts: forecasts
}
});
}),
// 获取股票报价(批量)
http.post('/api/stock/quotes', async ({ request }) => {
await delay(200);
@@ -421,7 +483,19 @@ export const stockHandlers = [
// 行业和指数标签
industry_l1: industryInfo.industry_l1,
industry: industryInfo.industry,
index_tags: industryInfo.index_tags || []
index_tags: industryInfo.index_tags || [],
// 关键指标
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
// 主力动态
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
};
});

View File

@@ -36,6 +36,58 @@ import {
FaChevronRight,
} from "react-icons/fa";
// 黑金主题配色
const THEME_PRESETS = {
blackGold: {
bg: "#0A0E17",
cardBg: "#1A1F2E",
cardHoverBg: "#212633",
cardBorder: "rgba(212, 175, 55, 0.2)",
cardHoverBorder: "#D4AF37",
textPrimary: "#E8E9ED",
textSecondary: "#A0A4B8",
textMuted: "#6B7280",
gold: "#D4AF37",
goldLight: "#FFD54F",
inputBg: "#151922",
inputBorder: "#2D3748",
buttonBg: "#D4AF37",
buttonText: "#0A0E17",
buttonHoverBg: "#FFD54F",
badgeS: { bg: "rgba(255, 195, 0, 0.2)", color: "#FFD54F" },
badgeA: { bg: "rgba(249, 115, 22, 0.2)", color: "#FB923C" },
badgeB: { bg: "rgba(59, 130, 246, 0.2)", color: "#60A5FA" },
badgeC: { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" },
tagBg: "rgba(212, 175, 55, 0.15)",
tagColor: "#D4AF37",
spinnerColor: "#D4AF37",
},
default: {
bg: "white",
cardBg: "white",
cardHoverBg: "gray.50",
cardBorder: "gray.200",
cardHoverBorder: "blue.300",
textPrimary: "gray.800",
textSecondary: "gray.600",
textMuted: "gray.500",
gold: "blue.500",
goldLight: "blue.400",
inputBg: "white",
inputBorder: "gray.200",
buttonBg: "blue.500",
buttonText: "white",
buttonHoverBg: "blue.600",
badgeS: { bg: "red.100", color: "red.600" },
badgeA: { bg: "orange.100", color: "orange.600" },
badgeB: { bg: "yellow.100", color: "yellow.600" },
badgeC: { bg: "green.100", color: "green.600" },
tagBg: "cyan.50",
tagColor: "cyan.600",
spinnerColor: "blue.500",
},
};
/**
* 新闻动态 Tab 组件
*
@@ -48,6 +100,7 @@ import {
* - onSearch: 搜索提交回调 () => void
* - onPageChange: 分页回调 (page) => void
* - cardBg: 卡片背景色
* - themePreset: 主题预设 'blackGold' | 'default'
*/
const NewsEventsTab = ({
newsEvents = [],
@@ -65,7 +118,11 @@ const NewsEventsTab = ({
onSearch,
onPageChange,
cardBg,
themePreset = "default",
}) => {
// 获取主题配色
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
const isBlackGold = themePreset === "blackGold";
// 事件类型图标映射
const getEventTypeIcon = (eventType) => {
const iconMap = {
@@ -80,15 +137,25 @@ const NewsEventsTab = ({
return iconMap[eventType] || FaNewspaper;
};
// 重要性颜色映射
const getImportanceColor = (importance) => {
// 重要性颜色映射 - 根据主题返回不同配色
const getImportanceBadgeStyle = (importance) => {
if (isBlackGold) {
const styles = {
S: theme.badgeS,
A: theme.badgeA,
B: theme.badgeB,
C: theme.badgeC,
};
return styles[importance] || { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" };
}
// 默认主题使用 colorScheme
const colorMap = {
S: "red",
A: "orange",
B: "yellow",
C: "green",
};
return colorMap[importance] || "gray";
return { colorScheme: colorMap[importance] || "gray" };
};
// 处理搜索输入
@@ -129,19 +196,26 @@ const NewsEventsTab = ({
// 如果开始页大于1显示省略号
if (startPage > 1) {
pageButtons.push(
<Text key="start-ellipsis" fontSize="sm" color="gray.400">
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
}
for (let i = startPage; i <= endPage; i++) {
const isActive = i === currentPage;
pageButtons.push(
<Button
key={i}
size="sm"
variant={i === currentPage ? "solid" : "outline"}
colorScheme={i === currentPage ? "blue" : "gray"}
bg={isActive ? theme.buttonBg : (isBlackGold ? theme.inputBg : undefined)}
color={isActive ? theme.buttonText : theme.textSecondary}
borderColor={isActive ? theme.gold : theme.cardBorder}
borderWidth="1px"
_hover={{
bg: isActive ? theme.buttonHoverBg : theme.cardHoverBg,
borderColor: theme.gold
}}
onClick={() => handlePageChange(i)}
isDisabled={newsLoading}
>
@@ -153,7 +227,7 @@ const NewsEventsTab = ({
// 如果结束页小于总页数,显示省略号
if (endPage < totalPages) {
pageButtons.push(
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
@@ -164,7 +238,7 @@ const NewsEventsTab = ({
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg} shadow="md">
<Card bg={cardBg || theme.cardBg} shadow="md" borderColor={theme.cardBorder} borderWidth={isBlackGold ? "1px" : "0"}>
<CardBody>
<VStack spacing={4} align="stretch">
{/* 搜索框和统计信息 */}
@@ -172,17 +246,25 @@ const NewsEventsTab = ({
<HStack flex={1} minW="300px">
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
<SearchIcon color={theme.textMuted} />
</InputLeftElement>
<Input
placeholder="搜索相关新闻..."
value={searchQuery}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
bg={theme.inputBg}
borderColor={theme.inputBorder}
color={theme.textPrimary}
_placeholder={{ color: theme.textMuted }}
_hover={{ borderColor: theme.gold }}
_focus={{ borderColor: theme.gold, boxShadow: `0 0 0 1px ${theme.gold}` }}
/>
</InputGroup>
<Button
colorScheme="blue"
bg={theme.buttonBg}
color={theme.buttonText}
_hover={{ bg: theme.buttonHoverBg }}
onClick={handleSearchSubmit}
isLoading={newsLoading}
minW="80px"
@@ -193,10 +275,10 @@ const NewsEventsTab = ({
{newsPagination.total > 0 && (
<HStack spacing={2}>
<Icon as={FaNewspaper} color="blue.500" />
<Text fontSize="sm" color="gray.600">
<Icon as={FaNewspaper} color={theme.gold} />
<Text fontSize="sm" color={theme.textSecondary}>
共找到{" "}
<Text as="span" fontWeight="bold" color="blue.600">
<Text as="span" fontWeight="bold" color={theme.gold}>
{newsPagination.total}
</Text>{" "}
条新闻
@@ -211,15 +293,15 @@ const NewsEventsTab = ({
{newsLoading ? (
<Center h="400px">
<VStack spacing={3}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.600">正在加载新闻...</Text>
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
<Text color={theme.textSecondary}>正在加载新闻...</Text>
</VStack>
</Center>
) : newsEvents.length > 0 ? (
<>
<VStack spacing={3} align="stretch">
{newsEvents.map((event, idx) => {
const importanceColor = getImportanceColor(
const importanceBadgeStyle = getImportanceBadgeStyle(
event.importance
);
const eventTypeIcon = getEventTypeIcon(event.event_type);
@@ -228,10 +310,12 @@ const NewsEventsTab = ({
<Card
key={event.id || idx}
variant="outline"
bg={theme.cardBg}
borderColor={theme.cardBorder}
_hover={{
bg: "gray.50",
bg: theme.cardHoverBg,
shadow: "md",
borderColor: "blue.300",
borderColor: theme.cardHoverBorder,
}}
transition="all 0.2s"
>
@@ -243,13 +327,14 @@ const NewsEventsTab = ({
<HStack>
<Icon
as={eventTypeIcon}
color="blue.500"
color={theme.gold}
boxSize={5}
/>
<Text
fontWeight="bold"
fontSize="lg"
lineHeight="1.3"
color={theme.textPrimary}
>
{event.title}
</Text>
@@ -259,22 +344,29 @@ const NewsEventsTab = ({
<HStack spacing={2} flexWrap="wrap">
{event.importance && (
<Badge
colorScheme={importanceColor}
variant="solid"
{...(isBlackGold ? {} : { colorScheme: importanceBadgeStyle.colorScheme, variant: "solid" })}
bg={isBlackGold ? importanceBadgeStyle.bg : undefined}
color={isBlackGold ? importanceBadgeStyle.color : undefined}
px={2}
>
{event.importance}
</Badge>
)}
{event.event_type && (
<Badge colorScheme="blue" variant="outline">
<Badge
{...(isBlackGold ? {} : { colorScheme: "blue", variant: "outline" })}
bg={isBlackGold ? "rgba(59, 130, 246, 0.2)" : undefined}
color={isBlackGold ? "#60A5FA" : undefined}
borderColor={isBlackGold ? "rgba(59, 130, 246, 0.3)" : undefined}
>
{event.event_type}
</Badge>
)}
{event.invest_score && (
<Badge
colorScheme="purple"
variant="subtle"
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
color={isBlackGold ? "#A78BFA" : undefined}
>
投资分: {event.invest_score}
</Badge>
@@ -287,8 +379,9 @@ const NewsEventsTab = ({
<Tag
key={kidx}
size="sm"
colorScheme="cyan"
variant="subtle"
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
bg={isBlackGold ? theme.tagBg : undefined}
color={isBlackGold ? theme.tagColor : undefined}
>
{typeof keyword === "string"
? keyword
@@ -304,7 +397,7 @@ const NewsEventsTab = ({
{/* 右侧信息栏 */}
<VStack align="end" spacing={1} minW="100px">
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
{event.created_at
? new Date(
event.created_at
@@ -321,9 +414,9 @@ const NewsEventsTab = ({
<Icon
as={FaEye}
boxSize={3}
color="gray.400"
color={theme.textMuted}
/>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
{event.view_count}
</Text>
</HStack>
@@ -333,16 +426,16 @@ const NewsEventsTab = ({
<Icon
as={FaFire}
boxSize={3}
color="orange.400"
color={theme.goldLight}
/>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
{event.hot_score.toFixed(1)}
</Text>
</HStack>
)}
</HStack>
{event.creator && (
<Text fontSize="xs" color="gray.400">
<Text fontSize="xs" color={theme.textMuted}>
@{event.creator.username}
</Text>
)}
@@ -353,7 +446,7 @@ const NewsEventsTab = ({
{event.description && (
<Text
fontSize="sm"
color="gray.700"
color={theme.textSecondary}
lineHeight="1.6"
>
{event.description}
@@ -367,18 +460,18 @@ const NewsEventsTab = ({
<Box
pt={2}
borderTop="1px"
borderColor="gray.200"
borderColor={theme.cardBorder}
>
<HStack spacing={6} flexWrap="wrap">
<HStack spacing={1}>
<Icon
as={FaChartLine}
boxSize={3}
color="gray.500"
color={theme.textMuted}
/>
<Text
fontSize="xs"
color="gray.500"
color={theme.textMuted}
fontWeight="medium"
>
相关涨跌:
@@ -387,7 +480,7 @@ const NewsEventsTab = ({
{event.related_avg_chg !== null &&
event.related_avg_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
平均
</Text>
<Text
@@ -395,8 +488,8 @@ const NewsEventsTab = ({
fontWeight="bold"
color={
event.related_avg_chg > 0
? "red.500"
: "green.500"
? "#EF4444"
: "#10B981"
}
>
{event.related_avg_chg > 0 ? "+" : ""}
@@ -407,7 +500,7 @@ const NewsEventsTab = ({
{event.related_max_chg !== null &&
event.related_max_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
最大
</Text>
<Text
@@ -415,8 +508,8 @@ const NewsEventsTab = ({
fontWeight="bold"
color={
event.related_max_chg > 0
? "red.500"
: "green.500"
? "#EF4444"
: "#10B981"
}
>
{event.related_max_chg > 0 ? "+" : ""}
@@ -427,7 +520,7 @@ const NewsEventsTab = ({
{event.related_week_chg !== null &&
event.related_week_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
</Text>
<Text
@@ -435,8 +528,8 @@ const NewsEventsTab = ({
fontWeight="bold"
color={
event.related_week_chg > 0
? "red.500"
: "green.500"
? "#EF4444"
: "#10B981"
}
>
{event.related_week_chg > 0
@@ -465,7 +558,7 @@ const NewsEventsTab = ({
flexWrap="wrap"
>
{/* 分页信息 */}
<Text fontSize="sm" color="gray.600">
<Text fontSize="sm" color={theme.textSecondary}>
{newsPagination.page} / {newsPagination.pages}
</Text>
@@ -473,6 +566,11 @@ const NewsEventsTab = ({
<HStack spacing={2}>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(1)}
isDisabled={!newsPagination.has_prev || newsLoading}
leftIcon={<Icon as={FaChevronLeft} />}
@@ -481,6 +579,11 @@ const NewsEventsTab = ({
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page - 1)
}
@@ -494,6 +597,11 @@ const NewsEventsTab = ({
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page + 1)
}
@@ -503,6 +611,11 @@ const NewsEventsTab = ({
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(newsPagination.pages)}
isDisabled={!newsPagination.has_next || newsLoading}
rightIcon={<Icon as={FaChevronRight} />}
@@ -517,11 +630,11 @@ const NewsEventsTab = ({
) : (
<Center h="400px">
<VStack spacing={3}>
<Icon as={FaNewspaper} boxSize={16} color="gray.300" />
<Text color="gray.500" fontSize="lg" fontWeight="medium">
<Icon as={FaNewspaper} boxSize={16} color={isBlackGold ? theme.gold : "gray.300"} opacity={0.5} />
<Text color={theme.textSecondary} fontSize="lg" fontWeight="medium">
暂无相关新闻
</Text>
<Text fontSize="sm" color="gray.400">
<Text fontSize="sm" color={theme.textMuted}>
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
</Text>
</VStack>

View File

@@ -1,23 +1,49 @@
// src/views/Company/components/DynamicTracking/components/ForecastPanel.js
// 业绩预告面板
// 业绩预告面板 - 黑金主题
import React, { useState, useEffect, useCallback } from 'react';
import {
VStack,
Card,
CardBody,
HStack,
Badge,
Box,
Flex,
Text,
Spinner,
Center,
} from '@chakra-ui/react';
import { Tag } from 'antd';
import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig';
import { THEME } from '../../CompanyOverview/BasicInfoTab/config';
const API_BASE_URL = getApiBase();
// 黑金主题
const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.15)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
bgDark: '#1A202C',
cardBg: 'rgba(26, 32, 44, 0.6)',
text: '#E2E8F0',
textSecondary: '#A0AEC0',
positive: '#E53E3E',
negative: '#48BB78',
};
// 预告类型配色
const getForecastTypeStyle = (type) => {
const styles = {
'预增': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
'预减': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
'扭亏': { color: '#D4AF37', bg: 'rgba(212, 175, 55, 0.15)', border: 'rgba(212, 175, 55, 0.3)' },
'首亏': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
'续亏': { color: '#718096', bg: 'rgba(113, 128, 150, 0.15)', border: 'rgba(113, 128, 150, 0.3)' },
'续盈': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
'略增': { color: '#ED8936', bg: 'rgba(237, 137, 54, 0.15)', border: 'rgba(237, 137, 54, 0.3)' },
'略减': { color: '#38B2AC', bg: 'rgba(56, 178, 172, 0.15)', border: 'rgba(56, 178, 172, 0.3)' },
};
return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder };
};
const ForecastPanel = ({ stockCode }) => {
const [forecast, setForecast] = useState(null);
const [loading, setLoading] = useState(false);
@@ -63,33 +89,69 @@ const ForecastPanel = ({ stockCode }) => {
}
return (
<VStack spacing={4} align="stretch">
{forecast.forecasts.map((item, idx) => (
<Card key={idx} bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
<CardBody>
<HStack justify="space-between" mb={2}>
<Badge colorScheme="blue">{item.forecast_type}</Badge>
<VStack spacing={3} align="stretch">
{forecast.forecasts.map((item, idx) => {
const typeStyle = getForecastTypeStyle(item.forecast_type);
return (
<Box
key={idx}
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
p={4}
>
{/* 头部:类型标签 + 报告期 */}
<Flex justify="space-between" align="center" mb={3}>
<Tag
style={{
color: typeStyle.color,
background: typeStyle.bg,
border: `1px solid ${typeStyle.border}`,
fontWeight: 500,
}}
>
{item.forecast_type}
</Tag>
<Text fontSize="sm" color={THEME.textSecondary}>
报告期: {item.report_date}
</Text>
</HStack>
<Text mb={2} color={THEME.text}>{item.content}</Text>
</Flex>
{/* 内容 */}
<Text color={THEME.text} fontSize="sm" lineHeight="1.6" mb={3}>
{item.content}
</Text>
{/* 原因(如有) */}
{item.reason && (
<Text fontSize="sm" color={THEME.textSecondary}>
<Text fontSize="xs" color={THEME.textSecondary} mb={3}>
{item.reason}
</Text>
)}
{item.change_range?.lower && (
<HStack mt={2}>
<Text fontSize="sm" color={THEME.textSecondary}>预计变动范围:</Text>
<Badge colorScheme="green">
{/* 变动范围 */}
{item.change_range?.lower !== undefined && (
<Flex align="center" gap={2}>
<Text fontSize="sm" color={THEME.textSecondary}>
预计变动范围:
</Text>
<Tag
style={{
color: THEME.gold,
background: THEME.goldLight,
border: `1px solid ${THEME.goldBorder}`,
fontWeight: 600,
}}
>
{item.change_range.lower}% ~ {item.change_range.upper}%
</Badge>
</HStack>
</Tag>
</Flex>
)}
</CardBody>
</Card>
))}
</Box>
);
})}
</VStack>
);
};

View File

@@ -107,7 +107,7 @@ const NewsPanel = ({ stockCode }) => {
onSearchChange={handleSearchChange}
onSearch={handleSearch}
onPageChange={handlePageChange}
cardBg="white"
themePreset="blackGold"
/>
);
};

View File

@@ -1,14 +1,20 @@
/**
* 综合对比分析组件
* 综合对比分析组件 - 黑金主题
*/
import React from 'react';
import { Card, CardBody } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getComparisonChartOption } from '../utils';
import type { ComparisonAnalysisProps } from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
};
export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparison }) => {
if (!Array.isArray(comparison) || comparison.length === 0) return null;
@@ -29,11 +35,15 @@ export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparis
const chartOption = getComparisonChartOption(revenueData, profitData);
return (
<Card>
<CardBody>
<ReactECharts option={chartOption} style={{ height: '400px' }} />
</CardBody>
</Card>
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={chartOption} style={{ height: '350px' }} />
</Box>
);
};

View File

@@ -0,0 +1,188 @@
/**
* 财务全景面板组件 - 三列布局
* 复用 MarketDataView 的 MetricCard 组件
*/
import React, { memo } from 'react';
import { SimpleGrid, HStack, VStack, Text, Badge } from '@chakra-ui/react';
import { TrendingUp, Coins, Shield, TrendingDown, Activity, PieChart } from 'lucide-react';
import { formatUtils } from '@services/financialService';
// 复用 MarketDataView 的组件
import MetricCard from '../../MarketDataView/components/StockSummaryCard/MetricCard';
import { StatusTag } from '../../MarketDataView/components/StockSummaryCard/atoms';
import { darkGoldTheme } from '../../MarketDataView/constants';
import type { StockInfo, FinancialMetricsData } from '../types';
export interface FinancialOverviewPanelProps {
stockInfo: StockInfo | null;
financialMetrics: FinancialMetricsData[];
}
/**
* 获取成长状态
*/
const getGrowthStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value > 30) return { text: '高速增长', color: darkGoldTheme.green };
if (value > 10) return { text: '稳健增长', color: darkGoldTheme.gold };
if (value > 0) return { text: '低速增长', color: darkGoldTheme.orange };
if (value > -10) return { text: '小幅下滑', color: darkGoldTheme.orange };
return { text: '大幅下滑', color: darkGoldTheme.red };
};
/**
* 获取 ROE 状态
*/
const getROEStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value > 20) return { text: '优秀', color: darkGoldTheme.green };
if (value > 15) return { text: '良好', color: darkGoldTheme.gold };
if (value > 10) return { text: '一般', color: darkGoldTheme.orange };
return { text: '较低', color: darkGoldTheme.red };
};
/**
* 获取资产负债率状态
*/
const getDebtStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value < 40) return { text: '安全', color: darkGoldTheme.green };
if (value < 60) return { text: '适中', color: darkGoldTheme.gold };
if (value < 70) return { text: '偏高', color: darkGoldTheme.orange };
return { text: '风险', color: darkGoldTheme.red };
};
/**
* 财务全景面板组件
*/
export const FinancialOverviewPanel: React.FC<FinancialOverviewPanelProps> = memo(({
stockInfo,
financialMetrics,
}) => {
if (!stockInfo && (!financialMetrics || financialMetrics.length === 0)) {
return null;
}
// 获取最新一期财务指标
const latestMetrics = financialMetrics?.[0];
// 成长指标(来自 stockInfo
const revenueGrowth = stockInfo?.growth_rates?.revenue_growth;
const profitGrowth = stockInfo?.growth_rates?.profit_growth;
const forecast = stockInfo?.latest_forecast;
// 盈利指标(来自 financialMetrics
const roe = latestMetrics?.profitability?.roe;
const netProfitMargin = latestMetrics?.profitability?.net_profit_margin;
const grossMargin = latestMetrics?.profitability?.gross_margin;
// 风险与运营指标(来自 financialMetrics
const assetLiabilityRatio = latestMetrics?.solvency?.asset_liability_ratio;
const currentRatio = latestMetrics?.solvency?.current_ratio;
const rdExpenseRatio = latestMetrics?.expense_ratios?.rd_expense_ratio;
// 计算状态
const growthStatus = getGrowthStatus(profitGrowth);
const roeStatus = getROEStatus(roe);
const debtStatus = getDebtStatus(assetLiabilityRatio);
// 格式化涨跌显示
const formatGrowth = (value: number | undefined) => {
if (value === undefined || value === null) return '-';
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
};
return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
{/* 卡片1: 成长能力 */}
<MetricCard
title="成长能力"
subtitle="增长动力"
leftIcon={<TrendingUp size={14} />}
rightIcon={<Activity size={14} />}
mainLabel="利润增长"
mainValue={formatGrowth(profitGrowth)}
mainColor={profitGrowth !== undefined && profitGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
subText={
<VStack align="start" spacing={1}>
<HStack spacing={1} flexWrap="wrap">
<Text></Text>
<Text
fontWeight="bold"
color={revenueGrowth !== undefined && revenueGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
>
{formatGrowth(revenueGrowth)}
</Text>
<StatusTag text={growthStatus.text} color={growthStatus.color} />
</HStack>
{forecast && (
<Badge
bg="rgba(212, 175, 55, 0.15)"
color={darkGoldTheme.gold}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
>
{forecast.forecast_type} {forecast.content}
</Badge>
)}
</VStack>
}
/>
{/* 卡片2: 盈利与回报 */}
<MetricCard
title="盈利与回报"
subtitle="赚钱能力"
leftIcon={<Coins size={14} />}
rightIcon={<PieChart size={14} />}
mainLabel="ROE"
mainValue={formatUtils.formatPercent(roe)}
mainColor={darkGoldTheme.orange}
subText={
<VStack align="start" spacing={0.5}>
<Text color={roeStatus.color} fontWeight="medium">
{roeStatus.text}
</Text>
<HStack spacing={1} flexWrap="wrap">
<Text> {formatUtils.formatPercent(netProfitMargin)}</Text>
<Text>|</Text>
<Text> {formatUtils.formatPercent(grossMargin)}</Text>
</HStack>
</VStack>
}
/>
{/* 卡片3: 风险与运营 */}
<MetricCard
title="风险与运营"
subtitle="安全边际"
leftIcon={<Shield size={14} />}
rightIcon={<TrendingDown size={14} />}
mainLabel="资产负债率"
mainValue={formatUtils.formatPercent(assetLiabilityRatio)}
mainColor={debtStatus.color}
subText={
<VStack align="start" spacing={0.5}>
<Text color={debtStatus.color} fontWeight="medium">
{debtStatus.text}
</Text>
<HStack spacing={1} flexWrap="wrap">
<Text> {currentRatio?.toFixed(2) ?? '-'}</Text>
<Text>|</Text>
<Text> {formatUtils.formatPercent(rdExpenseRatio)}</Text>
</HStack>
</VStack>
}
/>
</SimpleGrid>
);
});
FinancialOverviewPanel.displayName = 'FinancialOverviewPanel';
export default FinancialOverviewPanel;

View File

@@ -4,18 +4,9 @@
import React, { useMemo } from 'react';
import {
VStack,
Grid,
GridItem,
Flex,
Box,
Heading,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Alert,
AlertIcon,
} from '@chakra-ui/react';
@@ -45,7 +36,7 @@ const BLACK_GOLD_THEME = {
algorithm: antTheme.darkAlgorithm,
token: {
colorPrimary: '#D4AF37',
colorBgContainer: 'transparent',
colorBgContainer: '#1A202C',
colorBgElevated: '#1a1a2e',
colorBorder: 'rgba(212, 175, 55, 0.3)',
colorText: '#e0e0e0',
@@ -65,41 +56,85 @@ const BLACK_GOLD_THEME = {
},
};
// 历史对比表格数据行类型
// 固定列背景样式(防止滚动时内容重叠)
const fixedColumnStyles = `
.main-business-table .ant-table-cell-fix-left,
.main-business-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.main-business-table .ant-table-thead .ant-table-cell-fix-left,
.main-business-table .ant-table-thead .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.main-business-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right {
background: #242d3d !important;
}
.main-business-table .ant-table-tbody > tr > td {
background: #1A202C !important;
}
`;
// 历史对比表格数据行类型(包含业务明细)
interface HistoricalRowData {
key: string;
business: string;
grossMargin?: number;
profit?: number;
[period: string]: string | number | undefined;
}
// 历史对比表格组件
// 历史对比表格组件(整合业务明细)
interface HistoricalComparisonTableProps {
historicalData: (ProductClassification | IndustryClassification)[];
businessItems: BusinessItem[];
hasProductData: boolean;
latestReportType: string;
}
const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
historicalData,
businessItems,
hasProductData,
latestReportType,
}) => {
// 动态生成列配置
const columns: ColumnsType<HistoricalRowData> = useMemo(() => {
const cols: ColumnsType<HistoricalRowData> = [
{
title: '业务/期间',
title: '业务',
dataIndex: 'business',
key: 'business',
fixed: 'left',
width: 150,
},
{
title: `毛利率(${latestReportType})`,
dataIndex: 'grossMargin',
key: 'grossMargin',
align: 'right',
width: 120,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatPercent(value) : '-',
},
{
title: `利润(${latestReportType})`,
dataIndex: 'profit',
key: 'profit',
align: 'right',
width: 100,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatLargeNumber(value) : '-',
},
];
// 添加各期间列
// 添加各期间营收
historicalData.slice(0, 4).forEach((period) => {
cols.push({
title: period.report_type,
title: `营收(${period.report_type})`,
dataIndex: period.period,
key: period.period,
align: 'right',
@@ -112,9 +147,9 @@ const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
});
return cols;
}, [historicalData]);
}, [historicalData, latestReportType]);
// 生成表格数据
// 生成表格数据(包含业务明细)
const dataSource: HistoricalRowData[] = useMemo(() => {
return businessItems
.filter((item: BusinessItem) => item.content !== '合计')
@@ -122,8 +157,11 @@ const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
const row: HistoricalRowData = {
key: `${idx}`,
business: item.content,
grossMargin: item.gross_margin || item.profit_margin,
profit: item.profit,
};
// 添加各期间营收数据
historicalData.slice(0, 4).forEach((period) => {
const periodItems: BusinessItem[] = hasProductData
? (period as ProductClassification).products
@@ -145,13 +183,16 @@ const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
h="100%"
className="main-business-table"
>
<style>{fixedColumnStyles}</style>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4}>
<Box p={4} overflowX="auto">
<ConfigProvider theme={BLACK_GOLD_THEME}>
<AntTable<HistoricalRowData>
columns={columns}
@@ -218,77 +259,35 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
: (mainBusiness!.industry_classification! as IndustryClassification[]);
return (
<VStack spacing={4} align="stretch">
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
<GridItem>
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={pieOption} style={{ height: '300px' }} />
</Box>
</GridItem>
<GridItem>
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
- {latestPeriod.report_type}
</Heading>
</Box>
<Box p={4}>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th color={THEME.thColor} borderColor={THEME.border}></Th>
<Th isNumeric color={THEME.thColor} borderColor={THEME.border}></Th>
<Th isNumeric color={THEME.thColor} borderColor={THEME.border}>(%)</Th>
<Th isNumeric color={THEME.thColor} borderColor={THEME.border}></Th>
</Tr>
</Thead>
<Tbody>
{businessItems
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => (
<Tr key={idx}>
<Td color={THEME.textColor} borderColor={THEME.border}>{item.content}</Td>
<Td isNumeric color={THEME.textColor} borderColor={THEME.border}>
{formatUtils.formatLargeNumber(item.revenue)}
</Td>
<Td isNumeric color={THEME.textColor} borderColor={THEME.border}>
{formatUtils.formatPercent(item.gross_margin || item.profit_margin)}
</Td>
<Td isNumeric color={THEME.textColor} borderColor={THEME.border}>
{formatUtils.formatLargeNumber(item.profit)}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
</Box>
</GridItem>
</Grid>
<Flex
direction={{ base: 'column', lg: 'row' }}
gap={4}
>
{/* 左侧:饼图 */}
<Box
flexShrink={0}
w={{ base: '100%', lg: '340px' }}
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={pieOption} style={{ height: '280px' }} />
</Box>
{/* 历史对比 - Ant Design Table 黑金主题 */}
{historicalData.length > 1 && (
<HistoricalComparisonTable
historicalData={historicalData}
businessItems={businessItems}
hasProductData={hasProductData}
/>
)}
</VStack>
{/* 右侧:业务明细与历史对比表格 */}
<Box flex={1} minW={0} overflow="hidden">
{historicalData.length > 0 && (
<HistoricalComparisonTable
historicalData={historicalData}
businessItems={businessItems}
hasProductData={hasProductData}
latestReportType={latestPeriod.report_type}
/>
)}
</Box>
</Flex>
);
};

View File

@@ -57,8 +57,8 @@ export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
}}
>
<Grid templateColumns="repeat(6, 1fr)" gap={4} alignItems="center">
<GridItem colSpan={{ base: 6, md: 2 }}>
<Grid templateColumns="repeat(5, 1fr)" gap={4} alignItems="center">
<GridItem colSpan={{ base: 5, md: 2 }}>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
@@ -84,16 +84,6 @@ export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
</HStack>
</VStack>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
EPS
</StatLabel>
<StatNumber color={darkGoldTheme.goldLight} fontSize="lg">
{stockInfo.key_metrics?.eps?.toFixed(3) || '-'}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">

View File

@@ -3,6 +3,8 @@
*/
export { PeriodSelector } from './PeriodSelector';
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
// 保留旧组件导出(向后兼容)
export { KeyMetricsOverview } from './KeyMetricsOverview';
export { StockInfoHeader } from './StockInfoHeader';
export { BalanceSheetTable } from './BalanceSheetTable';

View File

@@ -52,7 +52,7 @@ import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer'
import { useFinancialData, type DataTypeKey } from './hooks';
import { COLORS } from './constants';
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
import { PeriodSelector, KeyMetricsOverview, StockInfoHeader, MainBusinessAnalysis } from './components';
import { PeriodSelector, FinancialOverviewPanel, MainBusinessAnalysis, ComparisonAnalysis } from './components';
import {
BalanceSheetTab,
IncomeStatementTab,
@@ -93,6 +93,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
cashflow,
financialMetrics,
mainBusiness,
comparison,
loading,
loadingTab,
error,
@@ -275,32 +276,29 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 股票信息头部 */}
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
{loading ? (
<Skeleton height="150px" />
<Skeleton height="100px" />
) : (
<StockInfoHeader
<FinancialOverviewPanel
stockInfo={stockInfo}
positiveColor={positiveColor}
negativeColor={negativeColor}
financialMetrics={financialMetrics}
/>
)}
{/* 关键指标速览 */}
{!loading && stockInfo && financialMetrics.length > 0 && (
<KeyMetricsOverview financialMetrics={financialMetrics} />
{/* 营收与利润趋势 */}
{!loading && comparison && comparison.length > 0 && (
<ComparisonAnalysis comparison={comparison} />
)}
{/* 主营业务 */}
{!loading && stockInfo && (
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody>
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
</Text>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</CardBody>
</Card>
<Box>
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
</Text>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</Box>
)}
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}

View File

@@ -91,7 +91,7 @@ export const getMetricChartOption = (
};
/**
* 生成营收与利润趋势图表配置
* 生成营收与利润趋势图表配置 - 黑金主题
* @param revenueData 营收数据
* @param profitData 利润数据
* @returns ECharts 配置
@@ -101,34 +101,96 @@ export const getComparisonChartOption = (
profitData: { period: string; value: number }[]
) => {
return {
backgroundColor: 'transparent',
title: {
text: '营收与利润趋势',
left: 'center',
textStyle: {
color: '#D4AF37',
fontSize: 16,
fontWeight: 'bold',
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(26, 32, 44, 0.95)',
borderColor: 'rgba(212, 175, 55, 0.3)',
textStyle: {
color: '#E2E8F0',
},
axisPointer: {
type: 'cross',
crossStyle: {
color: 'rgba(212, 175, 55, 0.5)',
},
},
},
legend: {
data: ['营业收入', '净利润'],
bottom: 0,
textStyle: {
color: '#A0AEC0',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '12%',
top: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: revenueData.map((d) => d.period),
axisLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.3)',
},
},
axisLabel: {
color: '#A0AEC0',
},
},
yAxis: [
{
type: 'value',
name: '营收(亿)',
position: 'left',
nameTextStyle: {
color: '#A0AEC0',
},
axisLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.3)',
},
},
axisLabel: {
color: '#A0AEC0',
},
splitLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.1)',
},
},
},
{
type: 'value',
name: '利润(亿)',
position: 'right',
nameTextStyle: {
color: '#A0AEC0',
},
axisLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.3)',
},
},
axisLabel: {
color: '#A0AEC0',
},
splitLine: {
show: false,
},
},
],
series: [
@@ -139,10 +201,10 @@ export const getComparisonChartOption = (
itemStyle: {
color: (params: { dataIndex: number; value: number }) => {
const idx = params.dataIndex;
if (idx === 0) return '#3182CE';
if (idx === 0) return '#D4AF37'; // 金色作为基准
const prevValue = revenueData[idx - 1].value;
const currValue = params.value;
// 中国市场颜色
// 红涨绿跌
return currValue >= prevValue ? '#EF4444' : '#10B981';
},
},
@@ -153,15 +215,40 @@ export const getComparisonChartOption = (
yAxisIndex: 1,
data: profitData.map((d) => d.value?.toFixed(2)),
smooth: true,
itemStyle: { color: '#F59E0B' },
lineStyle: { width: 2 },
itemStyle: { color: '#D4AF37' },
lineStyle: { width: 2, color: '#D4AF37' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(212, 175, 55, 0.3)' },
{ offset: 1, color: 'rgba(212, 175, 55, 0.05)' },
],
},
},
},
],
};
};
// 黑金主题饼图配色
const BLACK_GOLD_PIE_COLORS = [
'#D4AF37', // 金色
'#B8860B', // 深金色
'#FFD700', // 亮金色
'#DAA520', // 金菊色
'#CD853F', // 秘鲁色
'#F4A460', // 沙褐色
'#DEB887', // 实木色
'#D2691E', // 巧克力色
];
/**
* 生成主营业务饼图配置
* 生成主营业务饼图配置 - 黑金主题
* @param title 标题
* @param subtitle 副标题
* @param data 饼图数据
@@ -177,9 +264,22 @@ export const getMainBusinessPieOption = (
text: title,
subtext: subtitle,
left: 'center',
textStyle: {
color: '#D4AF37',
fontSize: 14,
},
subtextStyle: {
color: '#A0AEC0',
fontSize: 12,
},
},
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(26, 32, 44, 0.95)',
borderColor: 'rgba(212, 175, 55, 0.3)',
textStyle: {
color: '#E2E8F0',
},
formatter: (params: { name: string; value: number; percent: number }) => {
return `${params.name}<br/>营收: ${formatUtils.formatLargeNumber(
params.value
@@ -190,17 +290,34 @@ export const getMainBusinessPieOption = (
orient: 'vertical',
left: 'left',
top: 'center',
textStyle: {
color: '#E2E8F0',
fontSize: 12,
},
},
color: BLACK_GOLD_PIE_COLORS,
series: [
{
type: 'pie',
radius: '50%',
radius: '55%',
center: ['55%', '50%'],
data: data,
label: {
show: true,
color: '#E2E8F0',
fontSize: 11,
formatter: '{b}: {d}%',
},
labelLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.5)',
},
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
shadowColor: 'rgba(212, 175, 55, 0.5)',
},
},
},

View File

@@ -0,0 +1,37 @@
/**
* 通用图表卡片组件 - 黑金主题
*/
import React from 'react';
import { Box, Heading } from '@chakra-ui/react';
import { THEME } from '../constants';
import type { ChartCardProps } from '../types';
const ChartCard: React.FC<ChartCardProps> = ({ title, children }) => {
return (
<Box
bg={THEME.bgDark}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
overflow="hidden"
>
<Box
px={4}
py={3}
borderBottom="1px solid"
borderColor={THEME.goldBorder}
bg={THEME.goldLight}
>
<Heading size="sm" color={THEME.gold}>
{title}
</Heading>
</Box>
<Box p={4}>
{children}
</Box>
</Box>
);
};
export default ChartCard;

View File

@@ -0,0 +1,148 @@
/**
* 详细数据表格 - 纯 Ant Design 黑金主题
*/
import React, { useMemo } from 'react';
import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { DetailTableProps, DetailTableRow } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
algorithm: antTheme.darkAlgorithm,
token: {
colorPrimary: '#D4AF37',
colorBgContainer: '#1A202C',
colorBgElevated: '#1a1a2e',
colorBorder: 'rgba(212, 175, 55, 0.3)',
colorText: '#e0e0e0',
colorTextSecondary: '#a0a0a0',
borderRadius: 4,
fontSize: 13,
},
components: {
Table: {
headerBg: 'rgba(212, 175, 55, 0.1)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
borderColor: 'rgba(212, 175, 55, 0.2)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 表格样式
const tableStyles = `
.forecast-detail-table {
background: #1A202C;
border: 1px solid rgba(212, 175, 55, 0.3);
border-radius: 6px;
overflow: hidden;
}
.forecast-detail-table .table-header {
padding: 12px 16px;
border-bottom: 1px solid rgba(212, 175, 55, 0.3);
background: rgba(212, 175, 55, 0.1);
}
.forecast-detail-table .table-header h4 {
margin: 0;
color: #D4AF37;
font-size: 14px;
font-weight: 600;
}
.forecast-detail-table .table-body {
padding: 16px;
}
.forecast-detail-table .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
background: #242d3d !important;
}
.forecast-detail-table .ant-table-tbody > tr > td {
background: #1A202C !important;
}
.forecast-detail-table .metric-tag {
background: rgba(212, 175, 55, 0.15);
border-color: rgba(212, 175, 55, 0.3);
color: #D4AF37;
}
`;
interface TableRowData extends DetailTableRow {
key: string;
}
const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
const { years, rows } = data;
// 构建列配置
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '关键指标',
dataIndex: '指标',
key: '指标',
fixed: 'left',
width: 160,
render: (value: string) => (
<Tag className="metric-tag">{value}</Tag>
),
},
];
// 添加年份列
years.forEach((year) => {
cols.push({
title: year,
dataIndex: year,
key: year,
align: 'right',
width: 100,
render: (value: string | number | null) => value ?? '-',
});
});
return cols;
}, [years]);
// 构建数据源
const dataSource: TableRowData[] = useMemo(() => {
return rows.map((row, idx) => ({
...row,
key: `row-${idx}`,
}));
}, [rows]);
return (
<div className="forecast-detail-table">
<style>{tableStyles}</style>
<div className="table-header">
<h4></h4>
</div>
<div className="table-body">
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table<TableRowData>
columns={columns}
dataSource={dataSource}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
bordered
/>
</ConfigProvider>
</div>
</div>
);
};
export default DetailTable;

View File

@@ -0,0 +1,51 @@
/**
* EPS 趋势图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { EpsChartProps } from '../types';
const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.eps],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: {
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '元/股',
nameTextStyle: { color: THEME.textSecondary },
},
series: [
{
name: 'EPS(稀释)',
type: 'line',
data: data.eps,
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.15 },
symbol: 'circle',
symbolSize: 6,
},
],
}), [data]);
return (
<ChartCard title="EPS 趋势">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default EpsChart;

View File

@@ -0,0 +1,59 @@
/**
* 增长率分析图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { GrowthChartProps } from '../types';
const GrowthChart: React.FC<GrowthChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: {
...BASE_CHART_CONFIG.yAxis,
type: 'value',
axisLabel: {
...BASE_CHART_CONFIG.yAxis.axisLabel,
formatter: '{value}%',
},
},
series: [
{
name: '营收增长率(%)',
type: 'bar',
data: data.revenue_growth_pct,
itemStyle: {
color: (params: { value: number }) =>
params.value >= 0 ? THEME.positive : THEME.negative,
},
label: {
show: true,
position: 'top',
color: THEME.textSecondary,
fontSize: 10,
formatter: (params: { value: number }) =>
params.value ? `${params.value.toFixed(1)}%` : '',
},
},
],
}), [data]);
return (
<ChartCard title="增长率分析">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default GrowthChart;

View File

@@ -0,0 +1,69 @@
/**
* 营业收入与净利润趋势图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { IncomeProfitChartProps } from '../types';
const IncomeProfitChart: React.FC<IncomeProfitChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.income, CHART_COLORS.profit],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['营业总收入(百万元)', '归母净利润(百万元)'],
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '收入(百万元)',
nameTextStyle: { color: THEME.textSecondary },
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '利润(百万元)',
nameTextStyle: { color: THEME.textSecondary },
},
],
series: [
{
name: '营业总收入(百万元)',
type: 'line',
data: data.income,
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.1 },
},
{
name: '归母净利润(百万元)',
type: 'line',
yAxisIndex: 1,
data: data.profit,
smooth: true,
lineStyle: { width: 2 },
},
],
}), [data]);
return (
<ChartCard title="营业收入与净利润趋势">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default IncomeProfitChart;

View File

@@ -0,0 +1,144 @@
/**
* 营业收入、净利润趋势与增长率分析 - 合并图表
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { IncomeProfitTrend, GrowthBars } from '../types';
interface IncomeProfitGrowthChartProps {
incomeProfitData: IncomeProfitTrend;
growthData: GrowthBars;
}
const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
incomeProfitData,
growthData,
}) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: {
color: 'rgba(212, 175, 55, 0.5)',
},
},
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['营业总收入', '归母净利润', '营收增长率'],
bottom: 0,
},
grid: {
left: 60,
right: 60,
bottom: 50,
top: 40,
containLabel: false,
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: incomeProfitData.years,
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '金额(百万元)',
position: 'left',
nameTextStyle: { color: THEME.textSecondary },
axisLabel: {
color: THEME.textSecondary,
formatter: (value: number) => {
if (Math.abs(value) >= 1000) {
return (value / 1000).toFixed(0) + 'k';
}
return value.toFixed(0);
},
},
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '增长率(%)',
position: 'right',
nameTextStyle: { color: THEME.textSecondary },
axisLabel: {
color: THEME.textSecondary,
formatter: '{value}%',
},
splitLine: {
show: false,
},
},
],
series: [
{
name: '营业总收入',
type: 'bar',
data: incomeProfitData.income,
itemStyle: {
color: CHART_COLORS.income,
},
barMaxWidth: 30,
},
{
name: '归母净利润',
type: 'line',
data: incomeProfitData.profit,
smooth: true,
lineStyle: { width: 2, color: CHART_COLORS.profit },
itemStyle: { color: CHART_COLORS.profit },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(246, 173, 85, 0.3)' },
{ offset: 1, color: 'rgba(246, 173, 85, 0.05)' },
],
},
},
},
{
name: '营收增长率',
type: 'line',
yAxisIndex: 1,
data: growthData.revenue_growth_pct,
smooth: true,
lineStyle: { width: 2, type: 'dashed', color: '#10B981' },
itemStyle: {
color: (params: { value: number }) =>
params.value >= 0 ? THEME.positive : THEME.negative,
},
label: {
show: true,
position: 'top',
color: THEME.textSecondary,
fontSize: 10,
formatter: (params: { value: number }) =>
params.value !== null && params.value !== undefined
? `${params.value.toFixed(1)}%`
: '',
},
},
],
}), [incomeProfitData, growthData]);
return (
<ChartCard title="营收与利润趋势 · 增长率">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default IncomeProfitGrowthChart;

View File

@@ -0,0 +1,68 @@
/**
* PE 与 PEG 分析图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { PePegChartProps } from '../types';
const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.pe, CHART_COLORS.peg],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['PE', 'PEG'],
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: 'PE(倍)',
nameTextStyle: { color: THEME.textSecondary },
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: 'PEG',
nameTextStyle: { color: THEME.textSecondary },
},
],
series: [
{
name: 'PE',
type: 'line',
data: data.pe,
smooth: true,
lineStyle: { width: 2 },
},
{
name: 'PEG',
type: 'line',
yAxisIndex: 1,
data: data.peg,
smooth: true,
lineStyle: { width: 2 },
},
],
}), [data]);
return (
<ChartCard title="PE 与 PEG 分析">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default PePegChart;

View File

@@ -0,0 +1,11 @@
/**
* ForecastReport 子组件导出
*/
export { default as ChartCard } from './ChartCard';
export { default as IncomeProfitChart } from './IncomeProfitChart';
export { default as GrowthChart } from './GrowthChart';
export { default as IncomeProfitGrowthChart } from './IncomeProfitGrowthChart';
export { default as EpsChart } from './EpsChart';
export { default as PePegChart } from './PePegChart';
export { default as DetailTable } from './DetailTable';

View File

@@ -0,0 +1,84 @@
/**
* 盈利预测报表常量和图表配置
*/
// 黑金主题配色
export const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.1)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
bgDark: '#1A202C',
text: '#E2E8F0',
textSecondary: '#A0AEC0',
positive: '#E53E3E',
negative: '#10B981',
};
// 图表配色方案
export const CHART_COLORS = {
income: '#D4AF37', // 收入 - 金色
profit: '#F6AD55', // 利润 - 橙金色
growth: '#B8860B', // 增长 - 深金色
eps: '#DAA520', // EPS - 金菊色
pe: '#D4AF37', // PE - 金色
peg: '#CD853F', // PEG - 秘鲁色
};
// ECharts 基础配置(黑金主题)
export const BASE_CHART_CONFIG = {
backgroundColor: 'transparent',
textStyle: {
color: THEME.text,
},
tooltip: {
backgroundColor: 'rgba(26, 32, 44, 0.95)',
borderColor: THEME.goldBorder,
textStyle: {
color: THEME.text,
},
},
legend: {
textStyle: {
color: THEME.textSecondary,
},
},
grid: {
left: 50,
right: 20,
bottom: 40,
top: 40,
containLabel: false,
},
xAxis: {
axisLine: {
lineStyle: {
color: THEME.goldBorder,
},
},
axisLabel: {
color: THEME.textSecondary,
rotate: 30,
},
splitLine: {
show: false,
},
},
yAxis: {
axisLine: {
lineStyle: {
color: THEME.goldBorder,
},
},
axisLabel: {
color: THEME.textSecondary,
},
splitLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.1)',
},
},
},
};
// 图表高度
export const CHART_HEIGHT = 280;

View File

@@ -1,161 +0,0 @@
// 简易版公司盈利预测报表视图
import React, { useState, useEffect } from 'react';
import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack } from '@chakra-ui/react';
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { stockService } from '@services/eventService';
const ForecastReport = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) setData(resp.data);
} finally {
setLoading(false);
}
};
// 监听props中的stockCode变化
useEffect(() => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode, code]);
// 加载数据
useEffect(() => {
if (code) {
load();
}
}, [code]);
const years = data?.detail_table?.years || [];
const colors = ['#805AD5', '#38B2AC', '#F6AD55', '#63B3ED', '#E53E3E', '#10B981'];
const incomeProfitOption = data ? {
color: [colors[0], colors[4]],
tooltip: { trigger: 'axis' },
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.income_profit_trend.years, axisLabel: { rotate: 30 } },
yAxis: [
{ type: 'value', name: '收入(百万元)' },
{ type: 'value', name: '利润(百万元)' }
],
series: [
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.08 } },
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true, lineStyle: { width: 2 } }
]
} : {};
const growthOption = data ? {
color: [colors[2]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.growth_bars.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
series: [ {
name: '营收增长率(%)',
type: 'bar',
data: data.growth_bars.revenue_growth_pct,
itemStyle: { color: (params) => params.value >= 0 ? '#E53E3E' : '#10B981' }
} ]
} : {};
const epsOption = data ? {
color: [colors[3]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.eps_trend.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', name: '元/股' },
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true, areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 } } ]
} : {};
const pePegOption = data ? {
color: [colors[0], colors[1]],
tooltip: { trigger: 'axis' },
legend: { data: ['PE', 'PEG'] },
grid: { left: 40, right: 40, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.pe_peg_axes.years, axisLabel: { rotate: 30 } },
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
series: [
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
]
} : {};
return (
<Box p={4}>
<HStack align="center" justify="space-between" mb={4}>
<Heading size="md">盈利预测报表</Heading>
<Button
leftIcon={<RepeatIcon />}
size="sm"
variant="outline"
onClick={load}
isLoading={loading}
>
刷新数据
</Button>
</HStack>
{loading && !data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1,2,3,4].map(i => (
<Card key={i}>
<CardHeader><Skeleton height="18px" width="140px" /></CardHeader>
<CardBody>
<Skeleton height="320px" />
</CardBody>
</Card>
))}
</SimpleGrid>
)}
{data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">PE PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }} /></CardBody></Card>
</SimpleGrid>
)}
{data && (
<Card mt={4}>
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
<CardBody>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>关键指标</Th>
{years.map(y => <Th key={y}>{y}</Th>)}
</Tr>
</Thead>
<Tbody>
{data.detail_table.rows.map((row, idx) => (
<Tr key={idx}>
<Td><Tag>{row['指标']}</Tag></Td>
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
)}
</Box>
);
};
export default ForecastReport;

View File

@@ -0,0 +1,85 @@
/**
* 盈利预测报表视图 - 黑金主题
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, SimpleGrid, Skeleton } from '@chakra-ui/react';
import { stockService } from '@services/eventService';
import {
IncomeProfitGrowthChart,
EpsChart,
PePegChart,
DetailTable,
ChartCard,
} from './components';
import { CHART_HEIGHT } from './constants';
import type { ForecastReportProps, ForecastData } from './types';
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');
const [data, setData] = useState<ForecastData | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) {
setData(resp.data);
}
} finally {
setLoading(false);
}
}, [code]);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode, code]);
// 加载数据
useEffect(() => {
if (code) {
load();
}
}, [code, load]);
return (
<Box>
{/* 加载骨架屏 */}
{loading && !data && (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
{[1, 2, 3].map((i) => (
<ChartCard key={i} title="加载中...">
<Skeleton height={`${CHART_HEIGHT}px`} />
</ChartCard>
))}
</SimpleGrid>
)}
{/* 图表区域 - 3列布局 */}
{data && (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
<IncomeProfitGrowthChart
incomeProfitData={data.income_profit_trend}
growthData={data.growth_bars}
/>
<EpsChart data={data.eps_trend} />
<PePegChart data={data.pe_peg_axes} />
</SimpleGrid>
)}
{/* 详细数据表格 */}
{data && (
<Box mt={4}>
<DetailTable data={data.detail_table} />
</Box>
)}
</Box>
);
};
export default ForecastReport;

View File

@@ -0,0 +1,81 @@
/**
* 盈利预测报表类型定义
*/
// 收入利润趋势数据
export interface IncomeProfitTrend {
years: string[];
income: number[];
profit: number[];
}
// 增长率数据
export interface GrowthBars {
years: string[];
revenue_growth_pct: number[];
}
// EPS 趋势数据
export interface EpsTrend {
years: string[];
eps: number[];
}
// PE/PEG 数据
export interface PePegAxes {
years: string[];
pe: number[];
peg: number[];
}
// 详细表格行数据
export interface DetailTableRow {
指标: string;
[year: string]: string | number | null;
}
// 详细表格数据
export interface DetailTable {
years: string[];
rows: DetailTableRow[];
}
// 完整的预测报表数据
export interface ForecastData {
income_profit_trend: IncomeProfitTrend;
growth_bars: GrowthBars;
eps_trend: EpsTrend;
pe_peg_axes: PePegAxes;
detail_table: DetailTable;
}
// 组件 Props
export interface ForecastReportProps {
stockCode?: string;
}
export interface ChartCardProps {
title: string;
children: React.ReactNode;
height?: number;
}
export interface IncomeProfitChartProps {
data: IncomeProfitTrend;
}
export interface GrowthChartProps {
data: GrowthBars;
}
export interface EpsChartProps {
data: EpsTrend;
}
export interface PePegChartProps {
data: PePegAxes;
}
export interface DetailTableProps {
data: DetailTable;
}

View File

@@ -0,0 +1,87 @@
/**
* CompanyInfo - 公司信息原子组件
* 显示公司基本信息(成立日期、注册资本、所在地、官网、简介)
*/
import React, { memo } from 'react';
import { Box, Flex, HStack, Text, Link, Icon, Divider } from '@chakra-ui/react';
import { Calendar, Coins, MapPin, Globe } from 'lucide-react';
import { formatRegisteredCapital, formatDate } from '../../CompanyOverview/utils';
import { STOCK_CARD_THEME } from './theme';
export interface CompanyBasicInfo {
establish_date?: string;
reg_capital?: number;
province?: string;
city?: string;
website?: string;
company_intro?: string;
}
export interface CompanyInfoProps {
basicInfo: CompanyBasicInfo;
}
export const CompanyInfo: React.FC<CompanyInfoProps> = memo(({ basicInfo }) => {
const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME;
return (
<>
<Divider borderColor={borderColor} my={4} />
<Flex gap={8}>
{/* 左侧:公司关键属性 (flex=1) */}
<Box flex="1" minWidth="0">
<HStack spacing={4} flexWrap="wrap" fontSize="14px">
<HStack spacing={1}>
<Icon as={Calendar} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{formatDate(basicInfo.establish_date)}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Coins} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{formatRegisteredCapital(basicInfo.reg_capital)}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={MapPin} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{basicInfo.province} {basicInfo.city}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Globe} color={labelColor} boxSize={4} />
{basicInfo.website ? (
<Link
href={basicInfo.website}
isExternal
color={valueColor}
fontWeight="bold"
_hover={{ color: labelColor }}
>
访
</Link>
) : (
<Text color={valueColor}></Text>
)}
</HStack>
</HStack>
</Box>
{/* 右侧:公司简介 (flex=2) */}
<Box flex="2" minWidth="0" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text fontSize="14px" color={labelColor} noOfLines={2}>
<Text as="span" fontWeight="bold" color={valueColor}></Text>
{basicInfo.company_intro || '暂无'}
</Text>
</Box>
</Flex>
</>
);
});
CompanyInfo.displayName = 'CompanyInfo';

View File

@@ -0,0 +1,76 @@
/**
* KeyMetrics - 关键指标原子组件
* 显示 PE、EPS、PB、流通市值、52周波动
*/
import React, { memo } from 'react';
import { Box, VStack, HStack, Text } from '@chakra-ui/react';
import { formatPrice } from './formatters';
import { STOCK_CARD_THEME } from './theme';
export interface KeyMetricsProps {
pe: number;
eps?: number;
pb: number;
marketCap: string;
week52Low: number;
week52High: number;
}
export const KeyMetrics: React.FC<KeyMetricsProps> = memo(({
pe,
eps,
pb,
marketCap,
week52Low,
week52High,
}) => {
const { labelColor, valueColor, sectionTitleColor } = STOCK_CARD_THEME;
return (
<Box flex="1">
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}>(PE)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{pe.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>(EPS)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{eps?.toFixed(3) || '-'}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>(PB)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{pb.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{marketCap}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>52</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{formatPrice(week52Low)}-{formatPrice(week52High)}
</Text>
</HStack>
</VStack>
</Box>
);
});
KeyMetrics.displayName = 'KeyMetrics';

View File

@@ -0,0 +1,71 @@
/**
* MainForceInfo - 主力动态原子组件
* 显示主力净流入、机构持仓、买卖比例
*/
import React, { memo } from 'react';
import { Box, VStack, HStack, Text, Progress } from '@chakra-ui/react';
import { formatNetInflow } from './formatters';
import { STOCK_CARD_THEME } from './theme';
export interface MainForceInfoProps {
mainNetInflow: number;
institutionHolding: number;
buyRatio: number;
sellRatio: number;
}
export const MainForceInfo: React.FC<MainForceInfoProps> = memo(({
mainNetInflow,
institutionHolding,
buyRatio,
sellRatio,
}) => {
const { labelColor, valueColor, sectionTitleColor, borderColor, upColor, downColor } = STOCK_CARD_THEME;
const inflowColor = mainNetInflow >= 0 ? upColor : downColor;
return (
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
{formatNetInflow(mainNetInflow)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{institutionHolding.toFixed(2)}%
</Text>
</HStack>
{/* 买卖比例条 */}
<Box mt={1}>
<Progress
value={buyRatio}
size="sm"
sx={{
'& > div': { bg: upColor },
}}
bg={downColor}
borderRadius="full"
/>
<HStack justify="space-between" mt={1} fontSize="14px">
<Text color={upColor}>{buyRatio}%</Text>
<Text color={downColor}>{sellRatio}%</Text>
</HStack>
</Box>
</VStack>
</Box>
);
});
MainForceInfo.displayName = 'MainForceInfo';

View File

@@ -0,0 +1,43 @@
/**
* PriceDisplay - 价格显示原子组件
* 显示当前价格和涨跌幅 Badge
*/
import React, { memo } from 'react';
import { HStack, Text, Badge } from '@chakra-ui/react';
import { formatPrice, formatChangePercent } from './formatters';
import { STOCK_CARD_THEME } from './theme';
export interface PriceDisplayProps {
currentPrice: number;
changePercent: number;
}
export const PriceDisplay: React.FC<PriceDisplayProps> = memo(({
currentPrice,
changePercent,
}) => {
const { upColor, downColor } = STOCK_CARD_THEME;
const priceColor = changePercent >= 0 ? upColor : downColor;
return (
<HStack align="baseline" spacing={3} mb={3}>
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
{formatPrice(currentPrice)}
</Text>
<Badge
bg={changePercent >= 0 ? upColor : downColor}
color="#FFFFFF"
fontSize="20px"
fontWeight="bold"
px={3}
py={1}
borderRadius="md"
>
{formatChangePercent(changePercent)}
</Badge>
</HStack>
);
});
PriceDisplay.displayName = 'PriceDisplay';

View File

@@ -0,0 +1,59 @@
/**
* SecondaryQuote - 次要行情原子组件
* 显示今开、昨收、最高、最低
*/
import React, { memo } from 'react';
import { HStack, Text } from '@chakra-ui/react';
import { formatPrice } from './formatters';
import { STOCK_CARD_THEME } from './theme';
export interface SecondaryQuoteProps {
todayOpen: number;
yesterdayClose: number;
todayHigh: number;
todayLow: number;
}
export const SecondaryQuote: React.FC<SecondaryQuoteProps> = memo(({
todayOpen,
yesterdayClose,
todayHigh,
todayLow,
}) => {
const { labelColor, valueColor, borderColor, upColor, downColor } = STOCK_CARD_THEME;
return (
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(todayOpen)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(yesterdayClose)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={upColor} fontWeight="bold">
{formatPrice(todayHigh)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={downColor} fontWeight="bold">
{formatPrice(todayLow)}
</Text>
</Text>
</HStack>
);
});
SecondaryQuote.displayName = 'SecondaryQuote';

View File

@@ -0,0 +1,120 @@
/**
* StockHeader - 股票头部原子组件
* 显示股票名称、代码、行业标签、指数标签、操作按钮
*/
import React, { memo } from 'react';
import { Flex, HStack, Text, Badge, IconButton, Tooltip } from '@chakra-ui/react';
import { Share2 } from 'lucide-react';
import FavoriteButton from '@components/FavoriteButton';
import CompareStockInput from './CompareStockInput';
import { STOCK_CARD_THEME } from './theme';
export interface StockHeaderProps {
name: string;
code: string;
industryL1?: string;
industry?: string;
indexTags?: string[];
updateTime?: string;
// 关注相关
isInWatchlist?: boolean;
isWatchlistLoading?: boolean;
onWatchlistToggle?: () => void;
// 分享
onShare?: () => void;
// 对比相关
isCompareLoading?: boolean;
onCompare?: (stockCode: string) => void;
}
export const StockHeader: React.FC<StockHeaderProps> = memo(({
name,
code,
industryL1,
industry,
indexTags,
updateTime,
isInWatchlist = false,
isWatchlistLoading = false,
onWatchlistToggle,
onShare,
isCompareLoading = false,
onCompare,
}) => {
const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME;
return (
<Flex justify="space-between" align="center" mb={4}>
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
<HStack spacing={3} align="center">
{/* 股票名称 - 突出显示 */}
<Text fontSize="26px" fontWeight="800" color={valueColor}>
{name}
</Text>
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
({code})
</Text>
{/* 行业标签 */}
{(industryL1 || industry) && (
<Badge
bg="transparent"
color={labelColor}
fontSize="14px"
fontWeight="medium"
border="1px solid"
borderColor={borderColor}
px={2}
py={0.5}
borderRadius="md"
>
{industryL1 && industry
? `${industryL1} · ${industry}`
: industry || industryL1}
</Badge>
)}
{/* 指数标签 */}
{indexTags && indexTags.length > 0 && (
<Text fontSize="14px" color={labelColor}>
{indexTags.join('、')}
</Text>
)}
</HStack>
{/* 右侧:对比 + 关注 + 分享 + 时间 */}
<HStack spacing={3}>
{/* 股票对比输入 */}
<CompareStockInput
onCompare={onCompare || (() => {})}
isLoading={isCompareLoading}
currentStockCode={code}
/>
<FavoriteButton
isFavorite={isInWatchlist}
isLoading={isWatchlistLoading}
onClick={onWatchlistToggle || (() => {})}
colorScheme="gold"
size="sm"
/>
<Tooltip label="分享" placement="top">
<IconButton
aria-label="分享"
icon={<Share2 size={18} />}
variant="ghost"
color={labelColor}
size="sm"
onClick={onShare}
_hover={{ bg: 'whiteAlpha.100' }}
/>
</Tooltip>
<Text fontSize="14px" color={labelColor}>
{updateTime?.split(' ')[1] || '--:--'}
</Text>
</HStack>
</Flex>
);
});
StockHeader.displayName = 'StockHeader';

View File

@@ -0,0 +1,29 @@
/**
* StockQuoteCard 格式化工具函数
*/
/**
* 格式化价格显示
*/
export const formatPrice = (price: number): string => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/**
* 格式化涨跌幅显示
*/
export const formatChangePercent = (percent: number): string => {
const sign = percent >= 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
};
/**
* 格式化主力净流入显示
*/
export const formatNetInflow = (value: number): string => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}亿`;
};

View File

@@ -1,6 +1,27 @@
/**
* StockQuoteCard 组件导出
* StockQuoteCard 组件统一导出
*/
// 原子组件
export { PriceDisplay } from './PriceDisplay';
export { SecondaryQuote } from './SecondaryQuote';
export { KeyMetrics } from './KeyMetrics';
export { MainForceInfo } from './MainForceInfo';
export { CompanyInfo } from './CompanyInfo';
export { StockHeader } from './StockHeader';
// 复合组件
export { default as CompareStockInput } from './CompareStockInput';
export { default as StockCompareModal } from './StockCompareModal';
// 工具和主题
export { STOCK_CARD_THEME } from './theme';
export * from './formatters';
// 类型导出
export type { PriceDisplayProps } from './PriceDisplay';
export type { SecondaryQuoteProps } from './SecondaryQuote';
export type { KeyMetricsProps } from './KeyMetrics';
export type { MainForceInfoProps } from './MainForceInfo';
export type { CompanyInfoProps, CompanyBasicInfo } from './CompanyInfo';
export type { StockHeaderProps } from './StockHeader';

View File

@@ -0,0 +1,20 @@
/**
* StockQuoteCard 黑金主题配置
*/
export const STOCK_CARD_THEME = {
// 背景和边框
cardBg: '#1A202C',
borderColor: '#C9A961',
// 文字颜色
labelColor: '#C9A961',
valueColor: '#F4D03F',
sectionTitleColor: '#F4D03F',
// 涨跌颜色(红涨绿跌)
upColor: '#F44336',
downColor: '#4CAF50',
} as const;
export type StockCardTheme = typeof STOCK_CARD_THEME;

View File

@@ -2,6 +2,7 @@
* StockQuoteCard - 股票行情卡片组件
*
* 展示股票的实时行情、关键指标和主力动态
* 采用原子组件拆分,提高可维护性和复用性
*/
import React from 'react';
@@ -10,52 +11,23 @@ import {
Card,
CardBody,
Flex,
HStack,
VStack,
Text,
Badge,
Progress,
Skeleton,
IconButton,
Tooltip,
Divider,
Link,
Icon,
useDisclosure,
} from '@chakra-ui/react';
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
import { formatRegisteredCapital, formatDate } from '../CompanyOverview/utils';
import FavoriteButton from '@components/FavoriteButton';
import { CompareStockInput, StockCompareModal } from './components';
import {
StockHeader,
PriceDisplay,
SecondaryQuote,
KeyMetrics,
MainForceInfo,
CompanyInfo,
StockCompareModal,
STOCK_CARD_THEME,
} from './components';
import type { StockQuoteCardProps } from './types';
/**
* 格式化价格显示
*/
const formatPrice = (price: number): string => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/**
* 格式化涨跌幅显示
*/
const formatChangePercent = (percent: number): string => {
const sign = percent >= 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
};
/**
* 格式化主力净流入显示
*/
const formatNetInflow = (value: number): string => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}亿`;
};
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
data,
isLoading = false,
@@ -74,11 +46,6 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
// 对比弹窗控制
const { isOpen: isCompareModalOpen, onOpen: openCompareModal, onClose: closeCompareModal } = useDisclosure();
// 处理分享点击
const handleShare = () => {
onShare?.();
};
// 处理对比按钮点击
const handleCompare = (stockCode: string) => {
onCompare?.(stockCode);
@@ -91,16 +58,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
onCloseCompare?.();
};
// 黑金主题颜色配置
const cardBg = '#1A202C';
const borderColor = '#C9A961';
const labelColor = '#C9A961';
const valueColor = '#F4D03F';
const sectionTitleColor = '#F4D03F';
// 涨跌颜色(红涨绿跌)
const upColor = '#F44336'; // 涨 - 红色
const downColor = '#4CAF50'; // 跌 - 绿色
const { cardBg, borderColor } = STOCK_CARD_THEME;
// 加载中或无数据时显示骨架屏
if (isLoading || !data) {
@@ -117,82 +75,24 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
);
}
const priceColor = data.changePercent >= 0 ? upColor : downColor;
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
<Flex justify="space-between" align="center" mb={4}>
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
<HStack spacing={3} align="center">
{/* 股票名称 - 突出显示 */}
<Text fontSize="26px" fontWeight="800" color={valueColor}>
{data.name}
</Text>
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
({data.code})
</Text>
{/* 行业标签 */}
{(data.industryL1 || data.industry) && (
<Badge
bg="transparent"
color={labelColor}
fontSize="14px"
fontWeight="medium"
border="1px solid"
borderColor={borderColor}
px={2}
py={0.5}
borderRadius="md"
>
{data.industryL1 && data.industry
? `${data.industryL1} · ${data.industry}`
: data.industry || data.industryL1}
</Badge>
)}
{/* 指数标签 */}
{data.indexTags?.length > 0 && (
<Text fontSize="14px" color={labelColor}>
{data.indexTags.join('、')}
</Text>
)}
</HStack>
{/* 右侧:对比 + 关注 + 分享 + 时间 */}
<HStack spacing={3}>
{/* 股票对比输入 */}
<CompareStockInput
onCompare={handleCompare}
isLoading={isCompareLoading}
currentStockCode={data.code}
/>
<FavoriteButton
isFavorite={isInWatchlist}
isLoading={isWatchlistLoading}
onClick={onWatchlistToggle || (() => {})}
colorScheme="gold"
size="sm"
/>
<Tooltip label="分享" placement="top">
<IconButton
aria-label="分享"
icon={<Share2 size={18} />}
variant="ghost"
color={labelColor}
size="sm"
onClick={handleShare}
_hover={{ bg: 'whiteAlpha.100' }}
/>
</Tooltip>
<Text fontSize="14px" color={labelColor}>
{data.updateTime?.split(' ')[1] || '--:--'}
</Text>
</HStack>
</Flex>
<StockHeader
name={data.name}
code={data.code}
industryL1={data.industryL1}
industry={data.industry}
indexTags={data.indexTags}
updateTime={data.updateTime}
isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={onWatchlistToggle}
onShare={onShare}
isCompareLoading={isCompareLoading}
onCompare={handleCompare}
/>
{/* 股票对比弹窗 */}
<StockCompareModal
@@ -209,196 +109,39 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
<Flex gap={8}>
{/* 左栏:价格信息 (flex=1) */}
<Box flex="1" minWidth="0">
<HStack align="baseline" spacing={3} mb={3}>
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
{formatPrice(data.currentPrice)}
</Text>
<Badge
bg={data.changePercent >= 0 ? upColor : downColor}
color="#FFFFFF"
fontSize="20px"
fontWeight="bold"
px={3}
py={1}
borderRadius="md"
>
{formatChangePercent(data.changePercent)}
</Badge>
</HStack>
{/* 次要行情:今开 | 昨收 | 最高 | 最低 */}
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.todayOpen)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.yesterdayClose)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={upColor} fontWeight="bold">
{formatPrice(data.todayHigh)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={downColor} fontWeight="bold">
{formatPrice(data.todayLow)}
</Text>
</Text>
</HStack>
<PriceDisplay
currentPrice={data.currentPrice}
changePercent={data.changePercent}
/>
<SecondaryQuote
todayOpen={data.todayOpen}
yesterdayClose={data.yesterdayClose}
todayHigh={data.todayHigh}
todayLow={data.todayLow}
/>
</Box>
{/* 右栏:关键指标 + 主力动态 (flex=2) */}
<Flex flex="2" minWidth="0" gap={8} borderLeftWidth="1px" borderColor={borderColor} pl={8}>
{/* 关键指标 */}
<Box flex="1">
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}>(PE)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pe.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>(PB)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pb.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.marketCap}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>52</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{formatPrice(data.week52Low)}-{formatPrice(data.week52High)}
</Text>
</HStack>
</VStack>
</Box>
{/* 主力动态 */}
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
{formatNetInflow(data.mainNetInflow)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.institutionHolding.toFixed(2)}%
</Text>
</HStack>
{/* 买卖比例条 */}
<Box mt={1}>
<Progress
value={data.buyRatio}
size="sm"
sx={{
'& > div': { bg: upColor },
}}
bg={downColor}
borderRadius="full"
/>
<HStack justify="space-between" mt={1} fontSize="14px">
<Text color={upColor}>{data.buyRatio}%</Text>
<Text color={downColor}>{data.sellRatio}%</Text>
</HStack>
</Box>
</VStack>
</Box>
<KeyMetrics
pe={data.pe}
eps={data.eps}
pb={data.pb}
marketCap={data.marketCap}
week52Low={data.week52Low}
week52High={data.week52High}
/>
<MainForceInfo
mainNetInflow={data.mainNetInflow}
institutionHolding={data.institutionHolding}
buyRatio={data.buyRatio}
sellRatio={data.sellRatio}
/>
</Flex>
</Flex>
{/* 公司信息区块 - 1:2 布局 */}
{basicInfo && (
<>
<Divider borderColor={borderColor} my={4} />
<Flex gap={8}>
{/* 左侧:公司关键属性 (flex=1) */}
<Box flex="1" minWidth="0">
<HStack spacing={4} flexWrap="wrap" fontSize="14px">
<HStack spacing={1}>
<Icon as={Calendar} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{formatDate(basicInfo.establish_date)}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Coins} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{formatRegisteredCapital(basicInfo.reg_capital)}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={MapPin} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{basicInfo.province} {basicInfo.city}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Globe} color={labelColor} boxSize={4} />
{basicInfo.website ? (
<Link
href={basicInfo.website}
isExternal
color={valueColor}
fontWeight="bold"
_hover={{ color: labelColor }}
>
访
</Link>
) : (
<Text color={valueColor}></Text>
)}
</HStack>
</HStack>
</Box>
{/* 右侧:公司简介 (flex=2) */}
<Box flex="2" minWidth="0" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text fontSize="14px" color={labelColor} noOfLines={2}>
<Text as="span" fontWeight="bold" color={valueColor}></Text>
{basicInfo.company_intro || '暂无'}
</Text>
</Box>
</Flex>
</>
)}
{/* 公司信息区块 */}
{basicInfo && <CompanyInfo basicInfo={basicInfo} />}
</CardBody>
</Card>
);

View File

@@ -26,6 +26,7 @@ export interface StockQuoteCardData {
// 关键指标
pe: number; // 市盈率
eps?: number; // 每股收益
pb: number; // 市净率
marketCap: string; // 流通市值(已格式化,如 "2.73万亿"
week52Low: number; // 52周最低

View File

@@ -29,6 +29,7 @@ const transformQuoteData = (apiData, stockCode) => {
// 关键指标
pe: apiData.pe || apiData.pe_ttm || 0,
eps: apiData.eps || apiData.basic_eps || undefined,
pb: apiData.pb || apiData.pb_mrq || 0,
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
week52Low: apiData.week52_low || apiData.week52Low || 0,