Compare commits
15 Commits
bc6d370f55
...
e2f9f3278f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f9f3278f | ||
|
|
2d03c88f43 | ||
|
|
515b538c84 | ||
|
|
b52b54347d | ||
|
|
4954373b5b | ||
|
|
66cd6c3a29 | ||
|
|
ba99f55b16 | ||
|
|
2f69f83d16 | ||
|
|
3bd48e1ddd | ||
|
|
84914b3cca | ||
|
|
da455946a3 | ||
|
|
e734319ec4 | ||
|
|
faf2446203 | ||
|
|
83b24b6d54 | ||
|
|
ab7164681a |
@@ -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]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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亿
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -107,7 +107,7 @@ const NewsPanel = ({ stockCode }) => {
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearch={handleSearch}
|
||||
onPageChange={handlePageChange}
|
||||
cardBg="white"
|
||||
themePreset="blackGold"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
|
||||
export { PeriodSelector } from './PeriodSelector';
|
||||
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
|
||||
// 保留旧组件导出(向后兼容)
|
||||
export { KeyMetricsOverview } from './KeyMetricsOverview';
|
||||
export { StockInfoHeader } from './StockInfoHeader';
|
||||
export { BalanceSheetTable } from './BalanceSheetTable';
|
||||
|
||||
@@ -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 二级导航 */}
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
84
src/views/Company/components/ForecastReport/constants.ts
Normal file
84
src/views/Company/components/ForecastReport/constants.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
85
src/views/Company/components/ForecastReport/index.tsx
Normal file
85
src/views/Company/components/ForecastReport/index.tsx
Normal 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;
|
||||
81
src/views/Company/components/ForecastReport/types.ts
Normal file
81
src/views/Company/components/ForecastReport/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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)}亿`;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StockQuoteCardData {
|
||||
|
||||
// 关键指标
|
||||
pe: number; // 市盈率
|
||||
eps?: number; // 每股收益
|
||||
pb: number; // 市净率
|
||||
marketCap: string; // 流通市值(已格式化,如 "2.73万亿")
|
||||
week52Low: number; // 52周最低
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user