Compare commits
6 Commits
2cc16be585
...
6776e1d557
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6776e1d557 | ||
|
|
6eec7c6402 | ||
|
|
27b0e9375a | ||
|
|
e71f42b608 | ||
|
|
2c1acb41b4 | ||
|
|
23788bbebf |
@@ -45,6 +45,8 @@ export interface SubTabConfig {
|
|||||||
name: string;
|
name: string;
|
||||||
icon?: IconType | ComponentType;
|
icon?: IconType | ComponentType;
|
||||||
component?: ComponentType<any>;
|
component?: ComponentType<any>;
|
||||||
|
/** 自定义 Suspense fallback(如骨架屏) */
|
||||||
|
fallback?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -314,14 +316,16 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
{shouldRender && Component ? (
|
{shouldRender && Component ? (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<Center py={20}>
|
tab.fallback || (
|
||||||
<Spinner
|
<Center py={20}>
|
||||||
size="lg"
|
<Spinner
|
||||||
color={DEEP_SPACE.textGold}
|
size="lg"
|
||||||
thickness="3px"
|
color={DEEP_SPACE.textGold}
|
||||||
speed="0.8s"
|
thickness="3px"
|
||||||
/>
|
speed="0.8s"
|
||||||
</Center>
|
/>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Component {...componentProps} />
|
<Component {...componentProps} />
|
||||||
|
|||||||
@@ -1,663 +0,0 @@
|
|||||||
// src/views/Company/components/CompanyOverview/NewsEventsTab.js
|
|
||||||
// 新闻动态 Tab - 相关新闻事件列表 + 分页
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Text,
|
|
||||||
Badge,
|
|
||||||
Icon,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputLeftElement,
|
|
||||||
Tag,
|
|
||||||
Center,
|
|
||||||
Spinner,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { SearchIcon } from "@chakra-ui/icons";
|
|
||||||
import {
|
|
||||||
FaNewspaper,
|
|
||||||
FaBullhorn,
|
|
||||||
FaGavel,
|
|
||||||
FaFlask,
|
|
||||||
FaDollarSign,
|
|
||||||
FaShieldAlt,
|
|
||||||
FaFileAlt,
|
|
||||||
FaIndustry,
|
|
||||||
FaEye,
|
|
||||||
FaFire,
|
|
||||||
FaChartLine,
|
|
||||||
FaChevronLeft,
|
|
||||||
FaChevronRight,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { getEventDetailUrl } from "@/utils/idEncoder";
|
|
||||||
|
|
||||||
// 黑金主题配色(文字使用更亮的金色提高对比度)
|
|
||||||
const THEME_PRESETS = {
|
|
||||||
blackGold: {
|
|
||||||
bg: "#0A0E17",
|
|
||||||
cardBg: "#1A1F2E",
|
|
||||||
cardHoverBg: "#212633",
|
|
||||||
cardBorder: "rgba(212, 175, 55, 0.2)",
|
|
||||||
cardHoverBorder: "#F4D03F", // 亮金色
|
|
||||||
textPrimary: "#E8E9ED",
|
|
||||||
textSecondary: "#A0A4B8",
|
|
||||||
textMuted: "#6B7280",
|
|
||||||
gold: "#F4D03F", // 亮金色(用于文字)
|
|
||||||
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: "#F4D03F", // 亮金色
|
|
||||||
spinnerColor: "#F4D03F", // 亮金色
|
|
||||||
},
|
|
||||||
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 组件
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* - newsEvents: 新闻事件列表数组
|
|
||||||
* - newsLoading: 加载状态
|
|
||||||
* - newsPagination: 分页信息 { page, per_page, total, pages, has_next, has_prev }
|
|
||||||
* - searchQuery: 搜索关键词
|
|
||||||
* - onSearchChange: 搜索输入回调 (value) => void
|
|
||||||
* - onSearch: 搜索提交回调 () => void
|
|
||||||
* - onPageChange: 分页回调 (page) => void
|
|
||||||
* - cardBg: 卡片背景色
|
|
||||||
* - themePreset: 主题预设 'blackGold' | 'default'
|
|
||||||
*/
|
|
||||||
const NewsEventsTab = ({
|
|
||||||
newsEvents = [],
|
|
||||||
newsLoading = false,
|
|
||||||
newsPagination = {
|
|
||||||
page: 1,
|
|
||||||
per_page: 10,
|
|
||||||
total: 0,
|
|
||||||
pages: 0,
|
|
||||||
has_next: false,
|
|
||||||
has_prev: false,
|
|
||||||
},
|
|
||||||
searchQuery = "",
|
|
||||||
onSearchChange,
|
|
||||||
onSearch,
|
|
||||||
onPageChange,
|
|
||||||
cardBg,
|
|
||||||
themePreset = "default",
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// 获取主题配色
|
|
||||||
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
|
|
||||||
const isBlackGold = themePreset === "blackGold";
|
|
||||||
|
|
||||||
// 点击事件卡片,跳转到详情页
|
|
||||||
const handleEventClick = (eventId) => {
|
|
||||||
if (eventId) {
|
|
||||||
navigate(getEventDetailUrl(eventId));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 事件类型图标映射
|
|
||||||
const getEventTypeIcon = (eventType) => {
|
|
||||||
const iconMap = {
|
|
||||||
企业公告: FaBullhorn,
|
|
||||||
政策: FaGavel,
|
|
||||||
技术突破: FaFlask,
|
|
||||||
企业融资: FaDollarSign,
|
|
||||||
政策监管: FaShieldAlt,
|
|
||||||
政策动态: FaFileAlt,
|
|
||||||
行业事件: FaIndustry,
|
|
||||||
};
|
|
||||||
return iconMap[eventType] || FaNewspaper;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重要性颜色映射 - 根据主题返回不同配色
|
|
||||||
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 { colorScheme: colorMap[importance] || "gray" };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理搜索输入
|
|
||||||
const handleInputChange = (e) => {
|
|
||||||
onSearchChange?.(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理搜索提交
|
|
||||||
const handleSearchSubmit = () => {
|
|
||||||
onSearch?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理键盘事件
|
|
||||||
const handleKeyPress = (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSearchSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理分页
|
|
||||||
const handlePageChange = (page) => {
|
|
||||||
onPageChange?.(page);
|
|
||||||
// 滚动到列表顶部
|
|
||||||
document
|
|
||||||
.getElementById("news-list-top")
|
|
||||||
?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染分页按钮
|
|
||||||
const renderPaginationButtons = () => {
|
|
||||||
const { page: currentPage, pages: totalPages } = newsPagination;
|
|
||||||
const pageButtons = [];
|
|
||||||
|
|
||||||
// 显示当前页及前后各2页
|
|
||||||
let startPage = Math.max(1, currentPage - 2);
|
|
||||||
let endPage = Math.min(totalPages, currentPage + 2);
|
|
||||||
|
|
||||||
// 如果开始页大于1,显示省略号
|
|
||||||
if (startPage > 1) {
|
|
||||||
pageButtons.push(
|
|
||||||
<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"
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
{i}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果结束页小于总页数,显示省略号
|
|
||||||
if (endPage < totalPages) {
|
|
||||||
pageButtons.push(
|
|
||||||
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
|
|
||||||
...
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pageButtons;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack spacing={4} align="stretch">
|
|
||||||
<Card bg={cardBg || theme.cardBg} shadow="md" borderColor={theme.cardBorder} borderWidth={isBlackGold ? "1px" : "0"}>
|
|
||||||
<CardBody>
|
|
||||||
<VStack spacing={4} align="stretch">
|
|
||||||
{/* 搜索框和统计信息 */}
|
|
||||||
<HStack justify="space-between" flexWrap="wrap">
|
|
||||||
<HStack flex={1} minW="300px">
|
|
||||||
<InputGroup>
|
|
||||||
<InputLeftElement pointerEvents="none">
|
|
||||||
<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
|
|
||||||
bg={theme.buttonBg}
|
|
||||||
color={theme.buttonText}
|
|
||||||
_hover={{ bg: theme.buttonHoverBg }}
|
|
||||||
onClick={handleSearchSubmit}
|
|
||||||
isLoading={newsLoading}
|
|
||||||
minW="80px"
|
|
||||||
>
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{newsPagination.total > 0 && (
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Icon as={FaNewspaper} color={theme.gold} />
|
|
||||||
<Text fontSize="sm" color={theme.textSecondary}>
|
|
||||||
共找到{" "}
|
|
||||||
<Text as="span" fontWeight="bold" color={theme.gold}>
|
|
||||||
{newsPagination.total}
|
|
||||||
</Text>{" "}
|
|
||||||
条新闻
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<div id="news-list-top" />
|
|
||||||
|
|
||||||
{/* 新闻列表 */}
|
|
||||||
{newsLoading ? (
|
|
||||||
<Center h="400px">
|
|
||||||
<VStack spacing={3}>
|
|
||||||
<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 importanceBadgeStyle = getImportanceBadgeStyle(
|
|
||||||
event.importance
|
|
||||||
);
|
|
||||||
const eventTypeIcon = getEventTypeIcon(event.event_type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={event.id || idx}
|
|
||||||
variant="outline"
|
|
||||||
bg={theme.cardBg}
|
|
||||||
borderColor={theme.cardBorder}
|
|
||||||
cursor="pointer"
|
|
||||||
onClick={() => handleEventClick(event.id)}
|
|
||||||
_hover={{
|
|
||||||
bg: theme.cardHoverBg,
|
|
||||||
shadow: "md",
|
|
||||||
borderColor: theme.cardHoverBorder,
|
|
||||||
}}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
|
||||||
<CardBody p={4}>
|
|
||||||
<VStack align="stretch" spacing={3}>
|
|
||||||
{/* 标题栏 */}
|
|
||||||
<HStack justify="space-between" align="start">
|
|
||||||
<VStack align="start" spacing={2} flex={1}>
|
|
||||||
<HStack>
|
|
||||||
<Icon
|
|
||||||
as={eventTypeIcon}
|
|
||||||
color={theme.gold}
|
|
||||||
boxSize={5}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
fontWeight="bold"
|
|
||||||
fontSize="lg"
|
|
||||||
lineHeight="1.3"
|
|
||||||
color={theme.textPrimary}
|
|
||||||
>
|
|
||||||
{event.title}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 标签栏 */}
|
|
||||||
<HStack spacing={2} flexWrap="wrap">
|
|
||||||
{event.importance && (
|
|
||||||
<Badge
|
|
||||||
{...(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
|
|
||||||
{...(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
|
|
||||||
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
|
|
||||||
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
|
|
||||||
color={isBlackGold ? "#A78BFA" : undefined}
|
|
||||||
>
|
|
||||||
投资分: {event.invest_score}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{event.keywords && event.keywords.length > 0 && (
|
|
||||||
<>
|
|
||||||
{event.keywords
|
|
||||||
.slice(0, 4)
|
|
||||||
.map((keyword, kidx) => (
|
|
||||||
<Tag
|
|
||||||
key={kidx}
|
|
||||||
size="sm"
|
|
||||||
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
|
|
||||||
bg={isBlackGold ? theme.tagBg : undefined}
|
|
||||||
color={isBlackGold ? theme.tagColor : undefined}
|
|
||||||
>
|
|
||||||
{typeof keyword === "string"
|
|
||||||
? keyword
|
|
||||||
: keyword?.concept ||
|
|
||||||
keyword?.name ||
|
|
||||||
"未知"}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{/* 右侧信息栏 */}
|
|
||||||
<VStack align="end" spacing={1} minW="100px">
|
|
||||||
<Text fontSize="xs" color={theme.textMuted}>
|
|
||||||
{event.created_at
|
|
||||||
? new Date(
|
|
||||||
event.created_at
|
|
||||||
).toLocaleDateString("zh-CN", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
})
|
|
||||||
: ""}
|
|
||||||
</Text>
|
|
||||||
<HStack spacing={3}>
|
|
||||||
{event.view_count !== undefined && (
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon
|
|
||||||
as={FaEye}
|
|
||||||
boxSize={3}
|
|
||||||
color={theme.textMuted}
|
|
||||||
/>
|
|
||||||
<Text fontSize="xs" color={theme.textMuted}>
|
|
||||||
{event.view_count}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
{event.hot_score !== undefined && (
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon
|
|
||||||
as={FaFire}
|
|
||||||
boxSize={3}
|
|
||||||
color={theme.goldLight}
|
|
||||||
/>
|
|
||||||
<Text fontSize="xs" color={theme.textMuted}>
|
|
||||||
{event.hot_score.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
{event.creator && (
|
|
||||||
<Text fontSize="xs" color={theme.textMuted}>
|
|
||||||
@{event.creator.username}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
{event.description && (
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
color={theme.textSecondary}
|
|
||||||
lineHeight="1.6"
|
|
||||||
>
|
|
||||||
{event.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 收益率数据 */}
|
|
||||||
{(event.related_avg_chg !== null ||
|
|
||||||
event.related_max_chg !== null ||
|
|
||||||
event.related_week_chg !== null) && (
|
|
||||||
<Box
|
|
||||||
pt={2}
|
|
||||||
borderTop="1px"
|
|
||||||
borderColor={theme.cardBorder}
|
|
||||||
>
|
|
||||||
<HStack spacing={6} flexWrap="wrap">
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon
|
|
||||||
as={FaChartLine}
|
|
||||||
boxSize={3}
|
|
||||||
color={theme.textMuted}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
fontSize="xs"
|
|
||||||
color={theme.textMuted}
|
|
||||||
fontWeight="medium"
|
|
||||||
>
|
|
||||||
相关涨跌:
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
{event.related_avg_chg !== null &&
|
|
||||||
event.related_avg_chg !== undefined && (
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Text fontSize="xs" color={theme.textMuted}>
|
|
||||||
平均
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={
|
|
||||||
event.related_avg_chg > 0
|
|
||||||
? "#EF4444"
|
|
||||||
: "#10B981"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{event.related_avg_chg > 0 ? "+" : ""}
|
|
||||||
{event.related_avg_chg.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
{event.related_max_chg !== null &&
|
|
||||||
event.related_max_chg !== undefined && (
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Text fontSize="xs" color={theme.textMuted}>
|
|
||||||
最大
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={
|
|
||||||
event.related_max_chg > 0
|
|
||||||
? "#EF4444"
|
|
||||||
: "#10B981"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{event.related_max_chg > 0 ? "+" : ""}
|
|
||||||
{event.related_max_chg.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
{event.related_week_chg !== null &&
|
|
||||||
event.related_week_chg !== undefined && (
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Text fontSize="xs" color={theme.textMuted}>
|
|
||||||
周
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={
|
|
||||||
event.related_week_chg > 0
|
|
||||||
? "#EF4444"
|
|
||||||
: "#10B981"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{event.related_week_chg > 0
|
|
||||||
? "+"
|
|
||||||
: ""}
|
|
||||||
{event.related_week_chg.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{/* 分页控件 */}
|
|
||||||
{newsPagination.pages > 1 && (
|
|
||||||
<Box pt={4}>
|
|
||||||
<HStack
|
|
||||||
justify="space-between"
|
|
||||||
align="center"
|
|
||||||
flexWrap="wrap"
|
|
||||||
>
|
|
||||||
{/* 分页信息 */}
|
|
||||||
<Text fontSize="sm" color={theme.textSecondary}>
|
|
||||||
第 {newsPagination.page} / {newsPagination.pages} 页
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* 分页按钮 */}
|
|
||||||
<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} />}
|
|
||||||
>
|
|
||||||
首页
|
|
||||||
</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)
|
|
||||||
}
|
|
||||||
isDisabled={!newsPagination.has_prev || newsLoading}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 页码按钮 */}
|
|
||||||
{renderPaginationButtons()}
|
|
||||||
|
|
||||||
<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)
|
|
||||||
}
|
|
||||||
isDisabled={!newsPagination.has_next || newsLoading}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</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} />}
|
|
||||||
>
|
|
||||||
末页
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Center h="400px">
|
|
||||||
<VStack spacing={3}>
|
|
||||||
<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={theme.textMuted}>
|
|
||||||
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewsEventsTab;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// src/views/Company/components/CompanyOverview/index.tsx
|
|
||||||
// 公司档案 - 主组件(组合层)
|
|
||||||
// 注:StockQuoteCard 已移至 Company/index.tsx,放在 Tab 容器上方,切换 Tab 时始终可见
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import type { CompanyOverviewProps } from "./types";
|
|
||||||
|
|
||||||
// 子组件
|
|
||||||
import BasicInfoTab from "./BasicInfoTab";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 公司档案组件
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - 显示基本信息 Tab(内部懒加载各子 Tab 数据)
|
|
||||||
*
|
|
||||||
* 注意:
|
|
||||||
* - StockQuoteCard 已提升到 Company/index.tsx 中渲染
|
|
||||||
* - 确保切换 Tab 时股票行情卡片始终可见
|
|
||||||
*
|
|
||||||
* 懒加载策略:
|
|
||||||
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
|
||||||
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo)
|
|
||||||
*/
|
|
||||||
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
|
||||||
return (
|
|
||||||
<BasicInfoTab stockCode={stockCode} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CompanyOverview;
|
|
||||||
@@ -6,7 +6,7 @@ import TabContainer from '@components/TabContainer';
|
|||||||
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
|
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
|
||||||
|
|
||||||
// 子组件导入(Tab 内容组件)
|
// 子组件导入(Tab 内容组件)
|
||||||
import CompanyOverview from '../CompanyOverview';
|
import BasicInfoTab from '../CompanyOverview/BasicInfoTab';
|
||||||
import DeepAnalysis from '../DeepAnalysis';
|
import DeepAnalysis from '../DeepAnalysis';
|
||||||
import MarketDataView from '../MarketDataView';
|
import MarketDataView from '../MarketDataView';
|
||||||
import FinancialPanorama from '../FinancialPanorama';
|
import FinancialPanorama from '../FinancialPanorama';
|
||||||
@@ -17,7 +17,7 @@ import DynamicTracking from '../DynamicTracking';
|
|||||||
* Tab 组件映射
|
* Tab 组件映射
|
||||||
*/
|
*/
|
||||||
const TAB_COMPONENTS = {
|
const TAB_COMPONENTS = {
|
||||||
overview: CompanyOverview,
|
overview: BasicInfoTab,
|
||||||
analysis: DeepAnalysis,
|
analysis: DeepAnalysis,
|
||||||
market: MarketDataView,
|
market: MarketDataView,
|
||||||
financial: FinancialPanorama,
|
financial: FinancialPanorama,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/components/NewsEmptyState.tsx
|
||||||
|
// 空状态组件
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Center, VStack, Icon, Text } from '@chakra-ui/react';
|
||||||
|
import { FaNewspaper } from 'react-icons/fa';
|
||||||
|
import type { NewsEmptyStateProps } from '../types';
|
||||||
|
|
||||||
|
const NewsEmptyState: React.FC<NewsEmptyStateProps> = ({
|
||||||
|
searchQuery,
|
||||||
|
theme,
|
||||||
|
isBlackGold,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Center h="400px">
|
||||||
|
<VStack spacing={3}>
|
||||||
|
<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={theme.textMuted}>
|
||||||
|
{searchQuery ? '尝试修改搜索关键词' : '该公司暂无新闻动态'}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(NewsEmptyState);
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/components/NewsEventCard.tsx
|
||||||
|
// 新闻事件卡片组件
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Icon,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Tag,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { FaEye, FaFire, FaChartLine } from 'react-icons/fa';
|
||||||
|
import type { NewsEventCardProps } from '../types';
|
||||||
|
import {
|
||||||
|
getEventTypeIcon,
|
||||||
|
getImportanceBadgeStyle,
|
||||||
|
formatDate,
|
||||||
|
formatChange,
|
||||||
|
getChangeColor,
|
||||||
|
getKeywordText,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
|
const NewsEventCard: React.FC<NewsEventCardProps> = ({
|
||||||
|
event,
|
||||||
|
theme,
|
||||||
|
isBlackGold,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const importanceBadgeStyle = getImportanceBadgeStyle(event.importance, theme, isBlackGold);
|
||||||
|
const EventTypeIcon = getEventTypeIcon(event.event_type);
|
||||||
|
|
||||||
|
const hasRelatedChanges =
|
||||||
|
event.related_avg_chg !== null ||
|
||||||
|
event.related_max_chg !== null ||
|
||||||
|
event.related_week_chg !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant="outline"
|
||||||
|
bg={theme.cardBg}
|
||||||
|
borderColor={theme.cardBorder}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => onClick(event.id)}
|
||||||
|
_hover={{
|
||||||
|
bg: theme.cardHoverBg,
|
||||||
|
shadow: 'md',
|
||||||
|
borderColor: theme.cardHoverBorder,
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
<CardBody p={4}>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<HStack justify="space-between" align="start">
|
||||||
|
<VStack align="start" spacing={2} flex={1}>
|
||||||
|
<HStack>
|
||||||
|
<Icon as={EventTypeIcon} color={theme.gold} boxSize={5} />
|
||||||
|
<Text fontWeight="bold" fontSize="lg" lineHeight="1.3" color={theme.textPrimary}>
|
||||||
|
{event.title}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 标签栏 */}
|
||||||
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
|
{event.importance && (
|
||||||
|
<Badge
|
||||||
|
{...(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
|
||||||
|
{...(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
|
||||||
|
{...(isBlackGold ? {} : { colorScheme: 'purple', variant: 'subtle' })}
|
||||||
|
bg={isBlackGold ? 'rgba(139, 92, 246, 0.2)' : undefined}
|
||||||
|
color={isBlackGold ? '#A78BFA' : undefined}
|
||||||
|
>
|
||||||
|
投资分: {event.invest_score}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{event.keywords?.slice(0, 4).map((keyword, kidx) => (
|
||||||
|
<Tag
|
||||||
|
key={kidx}
|
||||||
|
size="sm"
|
||||||
|
{...(isBlackGold ? {} : { colorScheme: 'cyan', variant: 'subtle' })}
|
||||||
|
bg={isBlackGold ? theme.tagBg : undefined}
|
||||||
|
color={isBlackGold ? theme.tagColor : undefined}
|
||||||
|
>
|
||||||
|
{getKeywordText(keyword)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* 右侧信息栏 */}
|
||||||
|
<VStack align="end" spacing={1} minW="100px">
|
||||||
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
|
{formatDate(event.created_at)}
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={3}>
|
||||||
|
{event.view_count !== undefined && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Icon as={FaEye} boxSize={3} color={theme.textMuted} />
|
||||||
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
|
{event.view_count}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{event.hot_score !== undefined && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Icon as={FaFire} boxSize={3} color={theme.goldLight} />
|
||||||
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
|
{event.hot_score.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{event.creator && (
|
||||||
|
<Text fontSize="xs" color={theme.textMuted}>
|
||||||
|
@{event.creator.username}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
{event.description && (
|
||||||
|
<Text fontSize="sm" color={theme.textSecondary} lineHeight="1.6">
|
||||||
|
{event.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 收益率数据 */}
|
||||||
|
{hasRelatedChanges && (
|
||||||
|
<Box pt={2} borderTop="1px" borderColor={theme.cardBorder}>
|
||||||
|
<HStack spacing={6} flexWrap="wrap">
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Icon as={FaChartLine} boxSize={3} color={theme.textMuted} />
|
||||||
|
<Text fontSize="xs" color={theme.textMuted} fontWeight="medium">
|
||||||
|
相关涨跌:
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{event.related_avg_chg !== null && event.related_avg_chg !== undefined && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text fontSize="xs" color={theme.textMuted}>平均</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color={getChangeColor(event.related_avg_chg)}>
|
||||||
|
{formatChange(event.related_avg_chg)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{event.related_max_chg !== null && event.related_max_chg !== undefined && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text fontSize="xs" color={theme.textMuted}>最大</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color={getChangeColor(event.related_max_chg)}>
|
||||||
|
{formatChange(event.related_max_chg)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{event.related_week_chg !== null && event.related_week_chg !== undefined && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text fontSize="xs" color={theme.textMuted}>周</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color={getChangeColor(event.related_week_chg)}>
|
||||||
|
{formatChange(event.related_week_chg)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(NewsEventCard);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/components/NewsLoadingState.tsx
|
||||||
|
// 加载状态组件
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Center, VStack, Spinner, Text } from '@chakra-ui/react';
|
||||||
|
import type { NewsLoadingStateProps } from '../types';
|
||||||
|
|
||||||
|
const NewsLoadingState: React.FC<NewsLoadingStateProps> = ({ theme }) => {
|
||||||
|
return (
|
||||||
|
<Center h="400px">
|
||||||
|
<VStack spacing={3}>
|
||||||
|
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
|
||||||
|
<Text color={theme.textSecondary}>正在加载新闻...</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(NewsLoadingState);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/components/NewsPagination.tsx
|
||||||
|
// 分页组件
|
||||||
|
|
||||||
|
import React, { memo, useMemo } from 'react';
|
||||||
|
import { Box, HStack, Text, Button, Icon } from '@chakra-ui/react';
|
||||||
|
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||||
|
import type { NewsPaginationProps } from '../types';
|
||||||
|
|
||||||
|
const NewsPagination: React.FC<NewsPaginationProps> = ({
|
||||||
|
pagination,
|
||||||
|
onPageChange,
|
||||||
|
isLoading,
|
||||||
|
theme,
|
||||||
|
isBlackGold,
|
||||||
|
}) => {
|
||||||
|
const { page: currentPage, pages: totalPages, has_next, has_prev } = pagination;
|
||||||
|
|
||||||
|
// 渲染分页按钮
|
||||||
|
const pageButtons = useMemo(() => {
|
||||||
|
const buttons: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
// 显示当前页及前后各2页
|
||||||
|
const startPage = Math.max(1, currentPage - 2);
|
||||||
|
const endPage = Math.min(totalPages, currentPage + 2);
|
||||||
|
|
||||||
|
// 如果开始页大于1,显示省略号
|
||||||
|
if (startPage > 1) {
|
||||||
|
buttons.push(
|
||||||
|
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||||
|
...
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const isActive = i === currentPage;
|
||||||
|
buttons.push(
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
size="sm"
|
||||||
|
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={() => onPageChange(i)}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果结束页小于总页数,显示省略号
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
buttons.push(
|
||||||
|
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
|
||||||
|
...
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttons;
|
||||||
|
}, [currentPage, totalPages, theme, isBlackGold, isLoading, onPageChange]);
|
||||||
|
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pt={4}>
|
||||||
|
<HStack justify="space-between" align="center" flexWrap="wrap">
|
||||||
|
{/* 分页信息 */}
|
||||||
|
<Text fontSize="sm" color={theme.textSecondary}>
|
||||||
|
第 {currentPage} / {totalPages} 页
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 分页按钮 */}
|
||||||
|
<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={() => onPageChange(1)}
|
||||||
|
isDisabled={!has_prev || isLoading}
|
||||||
|
leftIcon={<Icon as={FaChevronLeft} />}
|
||||||
|
>
|
||||||
|
首页
|
||||||
|
</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={() => onPageChange(currentPage - 1)}
|
||||||
|
isDisabled={!has_prev || isLoading}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 页码按钮 */}
|
||||||
|
{pageButtons}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
bg={isBlackGold ? theme.inputBg : undefined}
|
||||||
|
color={theme.textSecondary}
|
||||||
|
borderColor={theme.cardBorder}
|
||||||
|
borderWidth="1px"
|
||||||
|
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
isDisabled={!has_next || isLoading}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</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={() => onPageChange(totalPages)}
|
||||||
|
isDisabled={!has_next || isLoading}
|
||||||
|
rightIcon={<Icon as={FaChevronRight} />}
|
||||||
|
>
|
||||||
|
末页
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(NewsPagination);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/components/NewsSearchBar.tsx
|
||||||
|
// 新闻搜索栏组件
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { HStack, Input, InputGroup, InputLeftElement, Button, Text, Icon } from '@chakra-ui/react';
|
||||||
|
import { SearchIcon } from '@chakra-ui/icons';
|
||||||
|
import { FaNewspaper } from 'react-icons/fa';
|
||||||
|
import type { NewsSearchBarProps } from '../types';
|
||||||
|
|
||||||
|
const NewsSearchBar: React.FC<NewsSearchBarProps> = ({
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
onSearch,
|
||||||
|
isLoading,
|
||||||
|
total,
|
||||||
|
theme,
|
||||||
|
isBlackGold,
|
||||||
|
}) => {
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onSearchChange(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack justify="space-between" flexWrap="wrap">
|
||||||
|
<HStack flex={1} minW="300px">
|
||||||
|
<InputGroup>
|
||||||
|
<InputLeftElement pointerEvents="none">
|
||||||
|
<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
|
||||||
|
bg={theme.buttonBg}
|
||||||
|
color={theme.buttonText}
|
||||||
|
_hover={{ bg: theme.buttonHoverBg }}
|
||||||
|
onClick={onSearch}
|
||||||
|
isLoading={isLoading}
|
||||||
|
minW="80px"
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={FaNewspaper} color={theme.gold} />
|
||||||
|
<Text fontSize="sm" color={theme.textSecondary}>
|
||||||
|
共找到{' '}
|
||||||
|
<Text as="span" fontWeight="bold" color={theme.gold}>
|
||||||
|
{total}
|
||||||
|
</Text>{' '}
|
||||||
|
条新闻
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(NewsSearchBar);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/components/index.ts
|
||||||
|
// 组件导出
|
||||||
|
|
||||||
|
export { default as NewsSearchBar } from './NewsSearchBar';
|
||||||
|
export { default as NewsEventCard } from './NewsEventCard';
|
||||||
|
export { default as NewsPagination } from './NewsPagination';
|
||||||
|
export { default as NewsEmptyState } from './NewsEmptyState';
|
||||||
|
export { default as NewsLoadingState } from './NewsLoadingState';
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/constants.ts
|
||||||
|
// 新闻动态 - 主题配置常量
|
||||||
|
|
||||||
|
import type { ThemeConfig } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑金主题配色(文字使用更亮的金色提高对比度)
|
||||||
|
*/
|
||||||
|
export const BLACK_GOLD_THEME: ThemeConfig = {
|
||||||
|
bg: '#0A0E17',
|
||||||
|
cardBg: '#1A1F2E',
|
||||||
|
cardHoverBg: '#212633',
|
||||||
|
cardBorder: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
cardHoverBorder: '#F4D03F',
|
||||||
|
textPrimary: '#E8E9ED',
|
||||||
|
textSecondary: '#A0A4B8',
|
||||||
|
textMuted: '#6B7280',
|
||||||
|
gold: '#F4D03F',
|
||||||
|
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: '#F4D03F',
|
||||||
|
spinnerColor: '#F4D03F',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认主题配色
|
||||||
|
*/
|
||||||
|
export const DEFAULT_THEME: ThemeConfig = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题预设映射
|
||||||
|
*/
|
||||||
|
export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||||
|
blackGold: BLACK_GOLD_THEME,
|
||||||
|
default: DEFAULT_THEME,
|
||||||
|
};
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/index.tsx
|
||||||
|
// 新闻动态 Tab 组件 - 黑金主题
|
||||||
|
|
||||||
|
import React, { memo, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { VStack, Card, CardBody } from '@chakra-ui/react';
|
||||||
|
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||||
|
import { THEME_PRESETS } from './constants';
|
||||||
|
import {
|
||||||
|
NewsSearchBar,
|
||||||
|
NewsEventCard,
|
||||||
|
NewsPagination,
|
||||||
|
NewsEmptyState,
|
||||||
|
NewsLoadingState,
|
||||||
|
} from './components';
|
||||||
|
import type { NewsEventsTabProps, NewsPagination as NewsPaginationType } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认分页配置
|
||||||
|
*/
|
||||||
|
const DEFAULT_PAGINATION: NewsPaginationType = {
|
||||||
|
page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
total: 0,
|
||||||
|
pages: 0,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻动态 Tab 组件
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - newsEvents: 新闻事件列表数组
|
||||||
|
* - newsLoading: 加载状态
|
||||||
|
* - newsPagination: 分页信息
|
||||||
|
* - searchQuery: 搜索关键词
|
||||||
|
* - onSearchChange: 搜索输入回调
|
||||||
|
* - onSearch: 搜索提交回调
|
||||||
|
* - onPageChange: 分页回调
|
||||||
|
* - cardBg: 卡片背景色
|
||||||
|
* - themePreset: 主题预设 'blackGold' | 'default'
|
||||||
|
*/
|
||||||
|
const NewsEventsTab: React.FC<NewsEventsTabProps> = ({
|
||||||
|
newsEvents = [],
|
||||||
|
newsLoading = false,
|
||||||
|
newsPagination = DEFAULT_PAGINATION,
|
||||||
|
searchQuery = '',
|
||||||
|
onSearchChange,
|
||||||
|
onSearch,
|
||||||
|
onPageChange,
|
||||||
|
cardBg,
|
||||||
|
themePreset = 'default',
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 获取主题配色
|
||||||
|
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
|
||||||
|
const isBlackGold = themePreset === 'blackGold';
|
||||||
|
|
||||||
|
// 点击事件卡片,跳转到详情页
|
||||||
|
const handleEventClick = useCallback(
|
||||||
|
(eventId: string | number | undefined) => {
|
||||||
|
if (eventId) {
|
||||||
|
navigate(getEventDetailUrl(eventId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理搜索输入
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
onSearchChange?.(value);
|
||||||
|
},
|
||||||
|
[onSearchChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理搜索提交
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
onSearch?.();
|
||||||
|
}, [onSearch]);
|
||||||
|
|
||||||
|
// 处理分页
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
onPageChange?.(page);
|
||||||
|
// 滚动到列表顶部
|
||||||
|
document.getElementById('news-list-top')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[onPageChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
<Card
|
||||||
|
bg={cardBg || theme.cardBg}
|
||||||
|
shadow="md"
|
||||||
|
borderColor={theme.cardBorder}
|
||||||
|
borderWidth={isBlackGold ? '1px' : '0'}
|
||||||
|
>
|
||||||
|
<CardBody>
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
{/* 搜索框和统计信息 */}
|
||||||
|
<NewsSearchBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
isLoading={newsLoading}
|
||||||
|
total={newsPagination.total}
|
||||||
|
theme={theme}
|
||||||
|
isBlackGold={isBlackGold}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="news-list-top" />
|
||||||
|
|
||||||
|
{/* 新闻列表 */}
|
||||||
|
{newsLoading ? (
|
||||||
|
<NewsLoadingState theme={theme} />
|
||||||
|
) : newsEvents.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
{newsEvents.map((event, idx) => (
|
||||||
|
<NewsEventCard
|
||||||
|
key={event.id || idx}
|
||||||
|
event={event}
|
||||||
|
theme={theme}
|
||||||
|
isBlackGold={isBlackGold}
|
||||||
|
onClick={handleEventClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* 分页控件 */}
|
||||||
|
<NewsPagination
|
||||||
|
pagination={newsPagination}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
isLoading={newsLoading}
|
||||||
|
theme={theme}
|
||||||
|
isBlackGold={isBlackGold}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<NewsEmptyState
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
theme={theme}
|
||||||
|
isBlackGold={isBlackGold}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(NewsEventsTab);
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/types.ts
|
||||||
|
// 新闻动态 - 类型定义
|
||||||
|
|
||||||
|
import type { IconType } from 'react-icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 徽章样式配置
|
||||||
|
*/
|
||||||
|
export interface BadgeStyle {
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
colorScheme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题配置
|
||||||
|
*/
|
||||||
|
export interface ThemeConfig {
|
||||||
|
bg: string;
|
||||||
|
cardBg: string;
|
||||||
|
cardHoverBg: string;
|
||||||
|
cardBorder: string;
|
||||||
|
cardHoverBorder: string;
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
textMuted: string;
|
||||||
|
gold: string;
|
||||||
|
goldLight: string;
|
||||||
|
inputBg: string;
|
||||||
|
inputBorder: string;
|
||||||
|
buttonBg: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonHoverBg: string;
|
||||||
|
badgeS: BadgeStyle;
|
||||||
|
badgeA: BadgeStyle;
|
||||||
|
badgeB: BadgeStyle;
|
||||||
|
badgeC: BadgeStyle;
|
||||||
|
tagBg: string;
|
||||||
|
tagColor: string;
|
||||||
|
spinnerColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻事件创建者
|
||||||
|
*/
|
||||||
|
export interface NewsEventCreator {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻事件关键词
|
||||||
|
*/
|
||||||
|
export interface NewsEventKeyword {
|
||||||
|
concept?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新闻事件数据
|
||||||
|
*/
|
||||||
|
export interface NewsEvent {
|
||||||
|
id?: string | number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
event_type?: string;
|
||||||
|
importance?: 'S' | 'A' | 'B' | 'C';
|
||||||
|
invest_score?: number;
|
||||||
|
keywords?: (string | NewsEventKeyword)[];
|
||||||
|
created_at?: string;
|
||||||
|
view_count?: number;
|
||||||
|
hot_score?: number;
|
||||||
|
creator?: NewsEventCreator;
|
||||||
|
related_avg_chg?: number | null;
|
||||||
|
related_max_chg?: number | null;
|
||||||
|
related_week_chg?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页信息
|
||||||
|
*/
|
||||||
|
export interface NewsPagination {
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
has_next: boolean;
|
||||||
|
has_prev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题预设类型
|
||||||
|
*/
|
||||||
|
export type ThemePreset = 'blackGold' | 'default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NewsEventsTab 组件 Props
|
||||||
|
*/
|
||||||
|
export interface NewsEventsTabProps {
|
||||||
|
/** 新闻事件列表 */
|
||||||
|
newsEvents?: NewsEvent[];
|
||||||
|
/** 加载状态 */
|
||||||
|
newsLoading?: boolean;
|
||||||
|
/** 分页信息 */
|
||||||
|
newsPagination?: NewsPagination;
|
||||||
|
/** 搜索关键词 */
|
||||||
|
searchQuery?: string;
|
||||||
|
/** 搜索输入回调 */
|
||||||
|
onSearchChange?: (value: string) => void;
|
||||||
|
/** 搜索提交回调 */
|
||||||
|
onSearch?: () => void;
|
||||||
|
/** 分页回调 */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
/** 卡片背景色 */
|
||||||
|
cardBg?: string;
|
||||||
|
/** 主题预设 */
|
||||||
|
themePreset?: ThemePreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索栏组件 Props
|
||||||
|
*/
|
||||||
|
export interface NewsSearchBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
total: number;
|
||||||
|
theme: ThemeConfig;
|
||||||
|
isBlackGold: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件卡片组件 Props
|
||||||
|
*/
|
||||||
|
export interface NewsEventCardProps {
|
||||||
|
event: NewsEvent;
|
||||||
|
theme: ThemeConfig;
|
||||||
|
isBlackGold: boolean;
|
||||||
|
onClick: (eventId: string | number | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页组件 Props
|
||||||
|
*/
|
||||||
|
export interface NewsPaginationProps {
|
||||||
|
pagination: NewsPagination;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
theme: ThemeConfig;
|
||||||
|
isBlackGold: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空状态组件 Props
|
||||||
|
*/
|
||||||
|
export interface NewsEmptyStateProps {
|
||||||
|
searchQuery: string;
|
||||||
|
theme: ThemeConfig;
|
||||||
|
isBlackGold: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载状态组件 Props
|
||||||
|
*/
|
||||||
|
export interface NewsLoadingStateProps {
|
||||||
|
theme: ThemeConfig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// src/views/Company/components/DynamicTracking/NewsEventsTab/utils.ts
|
||||||
|
// 新闻动态 - 工具函数
|
||||||
|
|
||||||
|
import type { IconType } from 'react-icons';
|
||||||
|
import {
|
||||||
|
FaNewspaper,
|
||||||
|
FaBullhorn,
|
||||||
|
FaGavel,
|
||||||
|
FaFlask,
|
||||||
|
FaDollarSign,
|
||||||
|
FaShieldAlt,
|
||||||
|
FaFileAlt,
|
||||||
|
FaIndustry,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import type { ThemeConfig, BadgeStyle } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件类型图标映射
|
||||||
|
*/
|
||||||
|
const EVENT_TYPE_ICONS: Record<string, IconType> = {
|
||||||
|
企业公告: FaBullhorn,
|
||||||
|
政策: FaGavel,
|
||||||
|
技术突破: FaFlask,
|
||||||
|
企业融资: FaDollarSign,
|
||||||
|
政策监管: FaShieldAlt,
|
||||||
|
政策动态: FaFileAlt,
|
||||||
|
行业事件: FaIndustry,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件类型对应的图标
|
||||||
|
*/
|
||||||
|
export const getEventTypeIcon = (eventType?: string): IconType => {
|
||||||
|
if (!eventType) return FaNewspaper;
|
||||||
|
return EVENT_TYPE_ICONS[eventType] || FaNewspaper;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取重要性徽章样式
|
||||||
|
*/
|
||||||
|
export const getImportanceBadgeStyle = (
|
||||||
|
importance: string | undefined,
|
||||||
|
theme: ThemeConfig,
|
||||||
|
isBlackGold: boolean
|
||||||
|
): BadgeStyle => {
|
||||||
|
if (isBlackGold) {
|
||||||
|
const styles: Record<string, BadgeStyle> = {
|
||||||
|
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: Record<string, string> = {
|
||||||
|
S: 'red',
|
||||||
|
A: 'orange',
|
||||||
|
B: 'yellow',
|
||||||
|
C: 'green',
|
||||||
|
};
|
||||||
|
return { colorScheme: colorMap[importance || ''] || 'gray', bg: '', color: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期
|
||||||
|
*/
|
||||||
|
export const formatDate = (dateStr?: string): string => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化涨跌幅
|
||||||
|
*/
|
||||||
|
export const formatChange = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
const prefix = value > 0 ? '+' : '';
|
||||||
|
return `${prefix}${value.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取涨跌幅颜色
|
||||||
|
*/
|
||||||
|
export const getChangeColor = (value: number | null | undefined): string => {
|
||||||
|
if (value === null || value === undefined) return '#9CA3AF';
|
||||||
|
return value > 0 ? '#EF4444' : '#10B981';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取关键词显示文本
|
||||||
|
*/
|
||||||
|
export const getKeywordText = (keyword: string | { concept?: string; name?: string }): string => {
|
||||||
|
if (typeof keyword === 'string') return keyword;
|
||||||
|
return keyword?.concept || keyword?.name || '未知';
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import axios from '@utils/axiosConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
|
import NewsEventsTab from '../NewsEventsTab';
|
||||||
|
|
||||||
const NewsPanel = ({ stockCode }) => {
|
const NewsPanel = ({ stockCode }) => {
|
||||||
const [newsEvents, setNewsEvents] = useState([]);
|
const [newsEvents, setNewsEvents] = useState([]);
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
/**
|
|
||||||
* 资产负债表组件 - Ant Design 黑金主题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo, memo } from 'react';
|
|
||||||
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
|
||||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { formatUtils } from '@services/financialService';
|
|
||||||
import {
|
|
||||||
CURRENT_ASSETS_METRICS,
|
|
||||||
NON_CURRENT_ASSETS_METRICS,
|
|
||||||
TOTAL_ASSETS_METRICS,
|
|
||||||
CURRENT_LIABILITIES_METRICS,
|
|
||||||
NON_CURRENT_LIABILITIES_METRICS,
|
|
||||||
TOTAL_LIABILITIES_METRICS,
|
|
||||||
EQUITY_METRICS,
|
|
||||||
} from '../constants';
|
|
||||||
import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
|
||||||
import type { BalanceSheetTableProps, MetricConfig } from '../types';
|
|
||||||
|
|
||||||
const TABLE_CLASS_NAME = 'balance-sheet-table';
|
|
||||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME);
|
|
||||||
|
|
||||||
// 表格行数据类型
|
|
||||||
interface TableRowData {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isCore?: boolean;
|
|
||||||
isTotal?: boolean;
|
|
||||||
isSection?: boolean;
|
|
||||||
indent?: number;
|
|
||||||
[period: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BalanceSheetTableInner: React.FC<BalanceSheetTableProps> = ({
|
|
||||||
data,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
positiveColor = 'red.500',
|
|
||||||
negativeColor = 'green.500',
|
|
||||||
}) => {
|
|
||||||
// 数组安全检查
|
|
||||||
if (!Array.isArray(data) || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box p={4} textAlign="center" color="gray.400">
|
|
||||||
暂无资产负债表数据
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxColumns = Math.min(data.length, 6);
|
|
||||||
const displayData = data.slice(0, maxColumns);
|
|
||||||
|
|
||||||
// 所有分类配置
|
|
||||||
const allSections = [
|
|
||||||
CURRENT_ASSETS_METRICS,
|
|
||||||
NON_CURRENT_ASSETS_METRICS,
|
|
||||||
TOTAL_ASSETS_METRICS,
|
|
||||||
CURRENT_LIABILITIES_METRICS,
|
|
||||||
NON_CURRENT_LIABILITIES_METRICS,
|
|
||||||
TOTAL_LIABILITIES_METRICS,
|
|
||||||
EQUITY_METRICS,
|
|
||||||
];
|
|
||||||
|
|
||||||
// 构建表格数据
|
|
||||||
const tableData = useMemo(() => {
|
|
||||||
const rows: TableRowData[] = [];
|
|
||||||
|
|
||||||
allSections.forEach((section) => {
|
|
||||||
// 添加分组标题行(汇总行不显示标题)
|
|
||||||
if (!['资产总计', '负债合计'].includes(section.title)) {
|
|
||||||
rows.push({
|
|
||||||
key: `section-${section.key}`,
|
|
||||||
name: section.title,
|
|
||||||
path: '',
|
|
||||||
isSection: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加指标行
|
|
||||||
section.metrics.forEach((metric: MetricConfig) => {
|
|
||||||
const row: TableRowData = {
|
|
||||||
key: metric.key,
|
|
||||||
name: metric.name,
|
|
||||||
path: metric.path,
|
|
||||||
isCore: metric.isCore,
|
|
||||||
isTotal: metric.isTotal || ['资产总计', '负债合计'].includes(section.title),
|
|
||||||
indent: metric.isTotal ? 0 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加各期数值
|
|
||||||
displayData.forEach((item) => {
|
|
||||||
const value = getValueByPath<number>(item, metric.path);
|
|
||||||
row[item.period] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
rows.push(row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}, [data, displayData]);
|
|
||||||
|
|
||||||
// 计算同比变化(使用共享函数)
|
|
||||||
const calcYoY = (
|
|
||||||
currentValue: number | undefined,
|
|
||||||
currentPeriod: string,
|
|
||||||
path: string
|
|
||||||
): number | null => {
|
|
||||||
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建列定义
|
|
||||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
|
||||||
const cols: ColumnsType<TableRowData> = [
|
|
||||||
{
|
|
||||||
title: '项目',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 200,
|
|
||||||
render: (name: string, record: TableRowData) => {
|
|
||||||
if (record.isSection) {
|
|
||||||
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HStack spacing={2} pl={record.indent ? 4 : 0}>
|
|
||||||
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
|
|
||||||
{record.isCore && (
|
|
||||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
核心
|
|
||||||
</ChakraBadge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...displayData.map((item) => ({
|
|
||||||
title: (
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
dataIndex: item.period,
|
|
||||||
key: item.period,
|
|
||||||
width: 120,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (value: number | undefined, record: TableRowData) => {
|
|
||||||
if (record.isSection) return null;
|
|
||||||
|
|
||||||
const yoy = calcYoY(value, item.period, record.path);
|
|
||||||
const formattedValue = formatUtils.formatLargeNumber(value, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<Box>
|
|
||||||
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
|
|
||||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box position="relative">
|
|
||||||
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
|
|
||||||
{formattedValue}
|
|
||||||
</Text>
|
|
||||||
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
|
|
||||||
<Text
|
|
||||||
position="absolute"
|
|
||||||
top="-12px"
|
|
||||||
right="0"
|
|
||||||
fontSize="10px"
|
|
||||||
className={yoy > 0 ? 'positive-change' : 'negative-change'}
|
|
||||||
>
|
|
||||||
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'action',
|
|
||||||
width: 40,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_: unknown, record: TableRowData) => {
|
|
||||||
if (record.isSection) return null;
|
|
||||||
return (
|
|
||||||
<Eye
|
|
||||||
size={14}
|
|
||||||
color="#D4AF37"
|
|
||||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [displayData, data, showMetricChart]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="balance-sheet-table">
|
|
||||||
<style>{tableStyles}</style>
|
|
||||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={tableData}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
rowClassName={(record) => {
|
|
||||||
if (record.isSection) return 'section-header';
|
|
||||||
if (record.isTotal) return 'total-row';
|
|
||||||
return '';
|
|
||||||
}}
|
|
||||||
onRow={(record) => ({
|
|
||||||
onClick: () => {
|
|
||||||
if (!record.isSection) {
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
|
||||||
})}
|
|
||||||
locale={{ emptyText: '暂无数据' }}
|
|
||||||
/>
|
|
||||||
</ConfigProvider>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BalanceSheetTable = memo(BalanceSheetTableInner);
|
|
||||||
export default BalanceSheetTable;
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* 现金流量表组件 - Ant Design 黑金主题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo, memo } from 'react';
|
|
||||||
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
|
||||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { formatUtils } from '@services/financialService';
|
|
||||||
import { CASHFLOW_METRICS } from '../constants';
|
|
||||||
import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
|
||||||
import type { CashflowTableProps } from '../types';
|
|
||||||
|
|
||||||
const TABLE_CLASS_NAME = 'cashflow-table';
|
|
||||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
|
||||||
.${TABLE_CLASS_NAME} .positive-value {
|
|
||||||
color: #E53E3E;
|
|
||||||
}
|
|
||||||
.${TABLE_CLASS_NAME} .negative-value {
|
|
||||||
color: #48BB78;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 核心指标
|
|
||||||
const CORE_METRICS = ['operating_net', 'free_cash_flow'];
|
|
||||||
|
|
||||||
// 表格行数据类型
|
|
||||||
interface TableRowData {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isCore?: boolean;
|
|
||||||
[period: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CashflowTableInner: React.FC<CashflowTableProps> = ({
|
|
||||||
data,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
positiveColor = 'red.500',
|
|
||||||
negativeColor = 'green.500',
|
|
||||||
}) => {
|
|
||||||
// 数组安全检查
|
|
||||||
if (!Array.isArray(data) || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box p={4} textAlign="center" color="gray.400">
|
|
||||||
暂无现金流量表数据
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxColumns = Math.min(data.length, 8);
|
|
||||||
const displayData = data.slice(0, maxColumns);
|
|
||||||
|
|
||||||
// 构建表格数据
|
|
||||||
const tableData = useMemo(() => {
|
|
||||||
return CASHFLOW_METRICS.map((metric) => {
|
|
||||||
const row: TableRowData = {
|
|
||||||
key: metric.key,
|
|
||||||
name: metric.name,
|
|
||||||
path: metric.path,
|
|
||||||
isCore: CORE_METRICS.includes(metric.key),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加各期数值
|
|
||||||
displayData.forEach((item) => {
|
|
||||||
const value = getValueByPath<number>(item, metric.path);
|
|
||||||
row[item.period] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
}, [data, displayData]);
|
|
||||||
|
|
||||||
// 计算同比变化(使用共享函数)
|
|
||||||
const calcYoY = (
|
|
||||||
currentValue: number | undefined,
|
|
||||||
currentPeriod: string,
|
|
||||||
path: string
|
|
||||||
): number | null => {
|
|
||||||
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建列定义
|
|
||||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
|
||||||
const cols: ColumnsType<TableRowData> = [
|
|
||||||
{
|
|
||||||
title: '项目',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 180,
|
|
||||||
render: (name: string, record: TableRowData) => (
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Text fontWeight="medium">{name}</Text>
|
|
||||||
{record.isCore && (
|
|
||||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
核心
|
|
||||||
</ChakraBadge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...displayData.map((item) => ({
|
|
||||||
title: (
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
dataIndex: item.period,
|
|
||||||
key: item.period,
|
|
||||||
width: 110,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (value: number | undefined, record: TableRowData) => {
|
|
||||||
const yoy = calcYoY(value, item.period, record.path);
|
|
||||||
const formattedValue = formatUtils.formatLargeNumber(value, 1);
|
|
||||||
const isNegative = value !== undefined && value < 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<Box>
|
|
||||||
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
|
|
||||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box position="relative">
|
|
||||||
<Text className={isNegative ? 'negative-value' : 'positive-value'}>
|
|
||||||
{formattedValue}
|
|
||||||
</Text>
|
|
||||||
{yoy !== null && Math.abs(yoy) > 50 && (
|
|
||||||
<Text
|
|
||||||
position="absolute"
|
|
||||||
top="-12px"
|
|
||||||
right="0"
|
|
||||||
fontSize="10px"
|
|
||||||
className={yoy > 0 ? 'positive-change' : 'negative-change'}
|
|
||||||
>
|
|
||||||
{yoy > 0 ? '↑' : '↓'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'action',
|
|
||||||
width: 40,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_: unknown, record: TableRowData) => (
|
|
||||||
<Eye
|
|
||||||
size={14}
|
|
||||||
color="#D4AF37"
|
|
||||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [displayData, data, showMetricChart]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="cashflow-table">
|
|
||||||
<style>{tableStyles}</style>
|
|
||||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={tableData}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
onRow={(record) => ({
|
|
||||||
onClick: () => {
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
},
|
|
||||||
style: { cursor: 'pointer' },
|
|
||||||
})}
|
|
||||||
locale={{ emptyText: '暂无数据' }}
|
|
||||||
/>
|
|
||||||
</ConfigProvider>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CashflowTable = memo(CashflowTableInner);
|
|
||||||
export default CashflowTable;
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
/**
|
|
||||||
* 财务指标表格组件 - Ant Design 黑金主题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { Box, Text, HStack, Badge as ChakraBadge, SimpleGrid, Card, CardBody, CardHeader, Heading, Button } from '@chakra-ui/react';
|
|
||||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { formatUtils } from '@services/financialService';
|
|
||||||
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
|
|
||||||
import { getValueByPath, isNegativeIndicator } from '../utils';
|
|
||||||
import type { FinancialMetricsTableProps } from '../types';
|
|
||||||
|
|
||||||
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
|
|
||||||
|
|
||||||
// Ant Design 黑金主题配置
|
|
||||||
const BLACK_GOLD_THEME = {
|
|
||||||
token: {
|
|
||||||
colorBgContainer: 'transparent',
|
|
||||||
colorText: '#E2E8F0',
|
|
||||||
colorTextHeading: '#D4AF37',
|
|
||||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Table: {
|
|
||||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
|
||||||
headerColor: '#D4AF37',
|
|
||||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
|
||||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
|
||||||
cellPaddingBlock: 8,
|
|
||||||
cellPaddingInline: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 黑金主题CSS
|
|
||||||
const tableStyles = `
|
|
||||||
.financial-metrics-table .ant-table {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .ant-table-thead > tr > th {
|
|
||||||
background: rgba(26, 32, 44, 0.8) !important;
|
|
||||||
color: #D4AF37 !important;
|
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .ant-table-tbody > tr > td {
|
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
|
||||||
color: #E2E8F0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .ant-table-tbody > tr:hover > td {
|
|
||||||
background: rgba(212, 175, 55, 0.08) !important;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .ant-table-cell-fix-left,
|
|
||||||
.financial-metrics-table .ant-table-cell-fix-right {
|
|
||||||
background: #1A202C !important;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
|
||||||
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
|
||||||
background: rgba(26, 32, 44, 0.95) !important;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .positive-change {
|
|
||||||
color: #E53E3E;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .negative-change {
|
|
||||||
color: #48BB78;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .positive-value {
|
|
||||||
color: #E53E3E;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .negative-value {
|
|
||||||
color: #48BB78;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .ant-table-placeholder {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
.financial-metrics-table .ant-empty-description {
|
|
||||||
color: #A0AEC0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 表格行数据类型
|
|
||||||
interface TableRowData {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isCore?: boolean;
|
|
||||||
[period: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
|
|
||||||
data,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
}) => {
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
|
|
||||||
|
|
||||||
// 数组安全检查
|
|
||||||
if (!Array.isArray(data) || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box p={4} textAlign="center" color="gray.400">
|
|
||||||
暂无财务指标数据
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxColumns = Math.min(data.length, 6);
|
|
||||||
const displayData = data.slice(0, maxColumns);
|
|
||||||
const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory];
|
|
||||||
|
|
||||||
// 构建表格数据
|
|
||||||
const tableData = useMemo(() => {
|
|
||||||
return currentCategory.metrics.map((metric) => {
|
|
||||||
const row: TableRowData = {
|
|
||||||
key: metric.key,
|
|
||||||
name: metric.name,
|
|
||||||
path: metric.path,
|
|
||||||
isCore: metric.isCore,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加各期数值
|
|
||||||
displayData.forEach((item) => {
|
|
||||||
const value = getValueByPath<number>(item, metric.path);
|
|
||||||
row[item.period] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
}, [data, displayData, currentCategory]);
|
|
||||||
|
|
||||||
// 计算同比变化
|
|
||||||
const calculateYoY = (
|
|
||||||
currentValue: number | undefined,
|
|
||||||
currentPeriod: string,
|
|
||||||
path: string
|
|
||||||
): number | null => {
|
|
||||||
if (currentValue === undefined || currentValue === null) return null;
|
|
||||||
|
|
||||||
const currentDate = new Date(currentPeriod);
|
|
||||||
const lastYearPeriod = data.find((item) => {
|
|
||||||
const date = new Date(item.period);
|
|
||||||
return (
|
|
||||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
|
||||||
date.getMonth() === currentDate.getMonth()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!lastYearPeriod) return null;
|
|
||||||
|
|
||||||
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
|
||||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
|
||||||
|
|
||||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建列定义
|
|
||||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
|
||||||
const cols: ColumnsType<TableRowData> = [
|
|
||||||
{
|
|
||||||
title: currentCategory.title,
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 200,
|
|
||||||
render: (name: string, record: TableRowData) => (
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Text fontWeight="medium" fontSize="xs">{name}</Text>
|
|
||||||
{record.isCore && (
|
|
||||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
核心
|
|
||||||
</ChakraBadge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...displayData.map((item) => ({
|
|
||||||
title: (
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
dataIndex: item.period,
|
|
||||||
key: item.period,
|
|
||||||
width: 100,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (value: number | undefined, record: TableRowData) => {
|
|
||||||
const yoy = calculateYoY(value, item.period, record.path);
|
|
||||||
const isNegative = isNegativeIndicator(record.key);
|
|
||||||
|
|
||||||
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
|
|
||||||
const changeColor = isNegative
|
|
||||||
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
|
||||||
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
|
||||||
|
|
||||||
// 成长能力指标特殊处理:正值红色,负值绿色
|
|
||||||
const valueColor = selectedCategory === 'growth'
|
|
||||||
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<Box>
|
|
||||||
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
|
|
||||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box position="relative">
|
|
||||||
<Text fontSize="xs" className={valueColor || undefined}>
|
|
||||||
{value?.toFixed(2) || '-'}
|
|
||||||
</Text>
|
|
||||||
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
|
|
||||||
<Text
|
|
||||||
position="absolute"
|
|
||||||
top="-12px"
|
|
||||||
right="0"
|
|
||||||
fontSize="10px"
|
|
||||||
className={changeColor}
|
|
||||||
>
|
|
||||||
{yoy > 0 ? '↑' : '↓'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'action',
|
|
||||||
width: 40,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_: unknown, record: TableRowData) => (
|
|
||||||
<Eye
|
|
||||||
size={14}
|
|
||||||
color="#D4AF37"
|
|
||||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [displayData, data, showMetricChart, currentCategory, selectedCategory]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{/* 分类选择器 */}
|
|
||||||
<HStack spacing={2} mb={4} flexWrap="wrap">
|
|
||||||
{(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).map(
|
|
||||||
([key, category]) => (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
size="sm"
|
|
||||||
variant={selectedCategory === key ? 'solid' : 'outline'}
|
|
||||||
bg={selectedCategory === key ? 'rgba(212, 175, 55, 0.3)' : 'transparent'}
|
|
||||||
color={selectedCategory === key ? '#D4AF37' : 'gray.400'}
|
|
||||||
borderColor="rgba(212, 175, 55, 0.3)"
|
|
||||||
_hover={{
|
|
||||||
bg: 'rgba(212, 175, 55, 0.2)',
|
|
||||||
borderColor: 'rgba(212, 175, 55, 0.5)',
|
|
||||||
}}
|
|
||||||
onClick={() => setSelectedCategory(key)}
|
|
||||||
>
|
|
||||||
{category.title.replace('指标', '')}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 指标表格 */}
|
|
||||||
<Box className="financial-metrics-table">
|
|
||||||
<style>{tableStyles}</style>
|
|
||||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={tableData}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
onRow={(record) => ({
|
|
||||||
onClick: () => {
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
},
|
|
||||||
style: { cursor: 'pointer' },
|
|
||||||
})}
|
|
||||||
locale={{ emptyText: '暂无数据' }}
|
|
||||||
/>
|
|
||||||
</ConfigProvider>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 关键指标快速对比 */}
|
|
||||||
{data[0] && (
|
|
||||||
<Card mt={4} bg="transparent" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
|
||||||
<CardHeader py={3} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
|
||||||
<Heading size="sm" color="#D4AF37">关键指标速览</Heading>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: 'ROE',
|
|
||||||
value: getValueByPath<number>(data[0], 'profitability.roe'),
|
|
||||||
format: 'percent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '毛利率',
|
|
||||||
value: getValueByPath<number>(data[0], 'profitability.gross_margin'),
|
|
||||||
format: 'percent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '净利率',
|
|
||||||
value: getValueByPath<number>(data[0], 'profitability.net_profit_margin'),
|
|
||||||
format: 'percent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '流动比率',
|
|
||||||
value: getValueByPath<number>(data[0], 'solvency.current_ratio'),
|
|
||||||
format: 'decimal',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '资产负债率',
|
|
||||||
value: getValueByPath<number>(data[0], 'solvency.asset_liability_ratio'),
|
|
||||||
format: 'percent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '研发费用率',
|
|
||||||
value: getValueByPath<number>(data[0], 'expense_ratios.rd_expense_ratio'),
|
|
||||||
format: 'percent',
|
|
||||||
},
|
|
||||||
].map((item, idx) => (
|
|
||||||
<Box key={idx} p={3} borderRadius="md" bg="rgba(212, 175, 55, 0.1)" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
|
||||||
<Text fontSize="xs" color="gray.400">
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="lg" fontWeight="bold" color="#D4AF37">
|
|
||||||
{item.format === 'percent'
|
|
||||||
? formatUtils.formatPercent(item.value)
|
|
||||||
: item.value?.toFixed(2) || '-'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FinancialMetricsTable;
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 财务全景骨架屏组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Skeleton,
|
||||||
|
SkeletonText,
|
||||||
|
SimpleGrid,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
// 黑金主题配色
|
||||||
|
const SKELETON_COLORS = {
|
||||||
|
startColor: 'rgba(26, 32, 44, 0.6)',
|
||||||
|
endColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务概览面板骨架屏
|
||||||
|
*/
|
||||||
|
const OverviewPanelSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={6}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Skeleton
|
||||||
|
height="20px"
|
||||||
|
width="100px"
|
||||||
|
mb={4}
|
||||||
|
{...SKELETON_COLORS}
|
||||||
|
/>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{[1, 2, 3, 4].map((j) => (
|
||||||
|
<HStack key={j} justify="space-between">
|
||||||
|
<Skeleton height="16px" width="80px" {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="16px" width="60px" {...SKELETON_COLORS} />
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
OverviewPanelSkeleton.displayName = 'OverviewPanelSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图表区域骨架屏
|
||||||
|
*/
|
||||||
|
const ChartSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<Skeleton height="20px" width="120px" mb={4} {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="200px" borderRadius="md" {...SKELETON_COLORS} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
ChartSkeleton.displayName = 'ChartSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主营业务骨架屏
|
||||||
|
*/
|
||||||
|
const MainBusinessSkeleton: React.FC = memo(() => (
|
||||||
|
<Box>
|
||||||
|
<Skeleton height="24px" width="100px" mb={4} {...SKELETON_COLORS} />
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<Card key={i} bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<Skeleton height="18px" width="80px" mb={3} {...SKELETON_COLORS} />
|
||||||
|
<VStack align="stretch" spacing={2}>
|
||||||
|
{[1, 2, 3].map((j) => (
|
||||||
|
<HStack key={j} justify="space-between">
|
||||||
|
<Skeleton height="14px" width="100px" {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="14px" width="50px" {...SKELETON_COLORS} />
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
MainBusinessSkeleton.displayName = 'MainBusinessSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab 区域骨架屏
|
||||||
|
*/
|
||||||
|
const TabSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody p={0}>
|
||||||
|
{/* Tab 栏 */}
|
||||||
|
<HStack
|
||||||
|
spacing={2}
|
||||||
|
p={3}
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.2)"
|
||||||
|
overflowX="auto"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
height="32px"
|
||||||
|
width="80px"
|
||||||
|
borderRadius="md"
|
||||||
|
{...SKELETON_COLORS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
{/* Tab 内容 */}
|
||||||
|
<Box p={4}>
|
||||||
|
<SkeletonText
|
||||||
|
noOfLines={8}
|
||||||
|
spacing={4}
|
||||||
|
skeletonHeight={4}
|
||||||
|
{...SKELETON_COLORS}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
TabSkeleton.displayName = 'TabSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务全景完整骨架屏
|
||||||
|
*/
|
||||||
|
const FinancialPanoramaSkeleton: React.FC = memo(() => (
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
<OverviewPanelSkeleton />
|
||||||
|
<ChartSkeleton />
|
||||||
|
<MainBusinessSkeleton />
|
||||||
|
<TabSkeleton />
|
||||||
|
</VStack>
|
||||||
|
));
|
||||||
|
|
||||||
|
FinancialPanoramaSkeleton.displayName = 'FinancialPanoramaSkeleton';
|
||||||
|
|
||||||
|
export { FinancialPanoramaSkeleton };
|
||||||
|
export default FinancialPanoramaSkeleton;
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
/**
|
|
||||||
* 通用财务表格组件 - Ant Design 黑金主题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
|
||||||
import { Table, ConfigProvider, Tooltip, Badge } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { formatUtils } from '@services/financialService';
|
|
||||||
|
|
||||||
// Ant Design 表格黑金主题配置
|
|
||||||
export const FINANCIAL_TABLE_THEME = {
|
|
||||||
token: {
|
|
||||||
colorBgContainer: 'transparent',
|
|
||||||
colorText: '#E2E8F0',
|
|
||||||
colorTextHeading: '#D4AF37',
|
|
||||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Table: {
|
|
||||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
|
||||||
headerColor: '#D4AF37',
|
|
||||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
|
||||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
|
||||||
cellPaddingBlock: 8,
|
|
||||||
cellPaddingInline: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 通用样式
|
|
||||||
export const tableStyles = `
|
|
||||||
.financial-table .ant-table {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-thead > tr > th {
|
|
||||||
background: rgba(26, 32, 44, 0.8) !important;
|
|
||||||
color: #D4AF37 !important;
|
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-tbody > tr > td {
|
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
|
||||||
color: #E2E8F0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-tbody > tr:hover > td {
|
|
||||||
background: rgba(212, 175, 55, 0.08) !important;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-tbody > tr.total-row > td {
|
|
||||||
background: rgba(212, 175, 55, 0.15) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-tbody > tr.section-header > td {
|
|
||||||
background: rgba(212, 175, 55, 0.08) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #D4AF37;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-cell-fix-left,
|
|
||||||
.financial-table .ant-table-cell-fix-right {
|
|
||||||
background: #1A202C !important;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
|
||||||
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
|
||||||
background: rgba(26, 32, 44, 0.95) !important;
|
|
||||||
}
|
|
||||||
.financial-table .positive-change {
|
|
||||||
color: #E53E3E;
|
|
||||||
}
|
|
||||||
.financial-table .negative-change {
|
|
||||||
color: #48BB78;
|
|
||||||
}
|
|
||||||
.financial-table .ant-table-placeholder {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
.financial-table .ant-empty-description {
|
|
||||||
color: #A0AEC0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 指标类型
|
|
||||||
export interface MetricConfig {
|
|
||||||
name: string;
|
|
||||||
key: string;
|
|
||||||
path: string;
|
|
||||||
isCore?: boolean;
|
|
||||||
isTotal?: boolean;
|
|
||||||
isSubtotal?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetricSectionConfig {
|
|
||||||
title: string;
|
|
||||||
key: string;
|
|
||||||
metrics: MetricConfig[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格行数据类型
|
|
||||||
export interface FinancialTableRow {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isCore?: boolean;
|
|
||||||
isTotal?: boolean;
|
|
||||||
isSection?: boolean;
|
|
||||||
indent?: number;
|
|
||||||
[period: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件 Props
|
|
||||||
export interface FinancialTableProps {
|
|
||||||
data: Array<{ period: string; [key: string]: unknown }>;
|
|
||||||
sections: MetricSectionConfig[];
|
|
||||||
onRowClick?: (name: string, key: string, path: string) => void;
|
|
||||||
loading?: boolean;
|
|
||||||
maxColumns?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取嵌套路径的值
|
|
||||||
const getValueByPath = (obj: Record<string, unknown>, path: string): number | undefined => {
|
|
||||||
const keys = path.split('.');
|
|
||||||
let value: unknown = obj;
|
|
||||||
for (const key of keys) {
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
value = (value as Record<string, unknown>)[key];
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return typeof value === 'number' ? value : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 计算同比变化
|
|
||||||
const calculateYoY = (
|
|
||||||
currentValue: number | undefined,
|
|
||||||
currentPeriod: string,
|
|
||||||
data: Array<{ period: string; [key: string]: unknown }>,
|
|
||||||
path: string
|
|
||||||
): number | null => {
|
|
||||||
if (currentValue === undefined || currentValue === null) return null;
|
|
||||||
|
|
||||||
const currentDate = new Date(currentPeriod);
|
|
||||||
const lastYearPeriod = data.find((item) => {
|
|
||||||
const date = new Date(item.period);
|
|
||||||
return (
|
|
||||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
|
||||||
date.getMonth() === currentDate.getMonth()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!lastYearPeriod) return null;
|
|
||||||
|
|
||||||
const lastYearValue = getValueByPath(lastYearPeriod as Record<string, unknown>, path);
|
|
||||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
|
||||||
|
|
||||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FinancialTable: React.FC<FinancialTableProps> = ({
|
|
||||||
data,
|
|
||||||
sections,
|
|
||||||
onRowClick,
|
|
||||||
loading = false,
|
|
||||||
maxColumns = 6,
|
|
||||||
}) => {
|
|
||||||
// 限制显示列数
|
|
||||||
const displayData = data.slice(0, maxColumns);
|
|
||||||
|
|
||||||
// 构建表格数据
|
|
||||||
const tableData: FinancialTableRow[] = [];
|
|
||||||
|
|
||||||
sections.forEach((section) => {
|
|
||||||
// 添加分组标题行(除了汇总行)
|
|
||||||
if (!section.title.includes('总计') && !section.title.includes('合计')) {
|
|
||||||
tableData.push({
|
|
||||||
key: `section-${section.key}`,
|
|
||||||
name: section.title,
|
|
||||||
path: '',
|
|
||||||
isSection: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加指标行
|
|
||||||
section.metrics.forEach((metric) => {
|
|
||||||
const row: FinancialTableRow = {
|
|
||||||
key: metric.key,
|
|
||||||
name: metric.name,
|
|
||||||
path: metric.path,
|
|
||||||
isCore: metric.isCore,
|
|
||||||
isTotal: metric.isTotal || section.title.includes('总计') || section.title.includes('合计'),
|
|
||||||
indent: metric.isTotal ? 0 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加各期数值
|
|
||||||
displayData.forEach((item) => {
|
|
||||||
const value = getValueByPath(item as Record<string, unknown>, metric.path);
|
|
||||||
row[item.period] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
tableData.push(row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 构建列定义
|
|
||||||
const columns: ColumnsType<FinancialTableRow> = [
|
|
||||||
{
|
|
||||||
title: '项目',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 180,
|
|
||||||
render: (name: string, record: FinancialTableRow) => {
|
|
||||||
if (record.isSection) {
|
|
||||||
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HStack spacing={2} pl={record.indent ? 4 : 0}>
|
|
||||||
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
|
|
||||||
{record.isCore && (
|
|
||||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
核心
|
|
||||||
</ChakraBadge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...displayData.map((item) => ({
|
|
||||||
title: (
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
dataIndex: item.period,
|
|
||||||
key: item.period,
|
|
||||||
width: 110,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (value: number | undefined, record: FinancialTableRow) => {
|
|
||||||
if (record.isSection) return null;
|
|
||||||
|
|
||||||
const yoy = calculateYoY(value, item.period, data, record.path);
|
|
||||||
const formattedValue = formatUtils.formatLargeNumber(value, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<Box>
|
|
||||||
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
|
|
||||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box position="relative">
|
|
||||||
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
|
|
||||||
{formattedValue}
|
|
||||||
</Text>
|
|
||||||
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
|
|
||||||
<Text
|
|
||||||
position="absolute"
|
|
||||||
top="-12px"
|
|
||||||
right="0"
|
|
||||||
fontSize="10px"
|
|
||||||
className={yoy > 0 ? 'positive-change' : 'negative-change'}
|
|
||||||
>
|
|
||||||
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'action',
|
|
||||||
width: 40,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_: unknown, record: FinancialTableRow) => {
|
|
||||||
if (record.isSection) return null;
|
|
||||||
return (
|
|
||||||
<Eye
|
|
||||||
size={14}
|
|
||||||
color="#D4AF37"
|
|
||||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRowClick?.(record.name, record.key, record.path);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="financial-table">
|
|
||||||
<style>{tableStyles}</style>
|
|
||||||
<ConfigProvider theme={FINANCIAL_TABLE_THEME}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={tableData}
|
|
||||||
pagination={false}
|
|
||||||
loading={loading}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
rowClassName={(record) => {
|
|
||||||
if (record.isSection) return 'section-header';
|
|
||||||
if (record.isTotal) return 'total-row';
|
|
||||||
return '';
|
|
||||||
}}
|
|
||||||
onRow={(record) => ({
|
|
||||||
onClick: () => {
|
|
||||||
if (!record.isSection && onRowClick) {
|
|
||||||
onRowClick(record.name, record.key, record.path);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
|
||||||
})}
|
|
||||||
locale={{ emptyText: '暂无数据' }}
|
|
||||||
/>
|
|
||||||
</ConfigProvider>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FinancialTable;
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
/**
|
|
||||||
* 利润表组件 - Ant Design 黑金主题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo, memo } from 'react';
|
|
||||||
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
|
|
||||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { formatUtils } from '@services/financialService';
|
|
||||||
import { INCOME_STATEMENT_SECTIONS } from '../constants';
|
|
||||||
import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
|
||||||
import type { IncomeStatementTableProps, MetricConfig } from '../types';
|
|
||||||
|
|
||||||
const TABLE_CLASS_NAME = 'income-statement-table';
|
|
||||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
|
||||||
.${TABLE_CLASS_NAME} .ant-table-tbody > tr.subtotal-row > td {
|
|
||||||
background: rgba(212, 175, 55, 0.1) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.${TABLE_CLASS_NAME} .negative-value {
|
|
||||||
color: #E53E3E;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 表格行数据类型
|
|
||||||
interface TableRowData {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isCore?: boolean;
|
|
||||||
isTotal?: boolean;
|
|
||||||
isSubtotal?: boolean;
|
|
||||||
isSection?: boolean;
|
|
||||||
indent?: number;
|
|
||||||
[period: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IncomeStatementTableInner: React.FC<IncomeStatementTableProps> = ({
|
|
||||||
data,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
positiveColor = 'red.500',
|
|
||||||
negativeColor = 'green.500',
|
|
||||||
}) => {
|
|
||||||
// 数组安全检查
|
|
||||||
if (!Array.isArray(data) || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box p={4} textAlign="center" color="gray.400">
|
|
||||||
暂无利润表数据
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxColumns = Math.min(data.length, 6);
|
|
||||||
const displayData = data.slice(0, maxColumns);
|
|
||||||
|
|
||||||
// 构建表格数据
|
|
||||||
const tableData = useMemo(() => {
|
|
||||||
const rows: TableRowData[] = [];
|
|
||||||
|
|
||||||
INCOME_STATEMENT_SECTIONS.forEach((section) => {
|
|
||||||
// 添加分组标题行
|
|
||||||
rows.push({
|
|
||||||
key: `section-${section.key}`,
|
|
||||||
name: section.title,
|
|
||||||
path: '',
|
|
||||||
isSection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加指标行
|
|
||||||
section.metrics.forEach((metric: MetricConfig) => {
|
|
||||||
const row: TableRowData = {
|
|
||||||
key: metric.key,
|
|
||||||
name: metric.name,
|
|
||||||
path: metric.path,
|
|
||||||
isCore: metric.isCore,
|
|
||||||
isTotal: metric.isTotal,
|
|
||||||
isSubtotal: metric.isSubtotal,
|
|
||||||
indent: metric.isTotal || metric.isSubtotal ? 0 : (metric.name.startsWith(' ') ? 2 : 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加各期数值
|
|
||||||
displayData.forEach((item) => {
|
|
||||||
const value = getValueByPath<number>(item, metric.path);
|
|
||||||
row[item.period] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
rows.push(row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}, [data, displayData]);
|
|
||||||
|
|
||||||
// 计算同比变化(使用共享函数)
|
|
||||||
const calcYoY = (
|
|
||||||
currentValue: number | undefined,
|
|
||||||
currentPeriod: string,
|
|
||||||
path: string
|
|
||||||
): number | null => {
|
|
||||||
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建列定义
|
|
||||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
|
||||||
const cols: ColumnsType<TableRowData> = [
|
|
||||||
{
|
|
||||||
title: '项目',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 250,
|
|
||||||
render: (name: string, record: TableRowData) => {
|
|
||||||
if (record.isSection) {
|
|
||||||
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HStack spacing={2} pl={record.indent ? record.indent * 4 : 0}>
|
|
||||||
<Text fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}>{name}</Text>
|
|
||||||
{record.isCore && (
|
|
||||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
核心
|
|
||||||
</ChakraBadge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...displayData.map((item) => ({
|
|
||||||
title: (
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
dataIndex: item.period,
|
|
||||||
key: item.period,
|
|
||||||
width: 120,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (value: number | undefined, record: TableRowData) => {
|
|
||||||
if (record.isSection) return null;
|
|
||||||
|
|
||||||
const yoy = calcYoY(value, item.period, record.path);
|
|
||||||
const isEPS = record.key.includes('eps');
|
|
||||||
const formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0);
|
|
||||||
const isNegative = value !== undefined && value < 0;
|
|
||||||
|
|
||||||
// 成本费用类负向指标,增长用绿色,减少用红色
|
|
||||||
const isCostItem = isNegativeIndicator(record.key);
|
|
||||||
const changeColor = isCostItem
|
|
||||||
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
|
||||||
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<Box>
|
|
||||||
<Text>数值: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
|
|
||||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box position="relative">
|
|
||||||
<Text
|
|
||||||
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}
|
|
||||||
className={isNegative ? 'negative-value' : undefined}
|
|
||||||
>
|
|
||||||
{formattedValue}
|
|
||||||
</Text>
|
|
||||||
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
|
|
||||||
<Text
|
|
||||||
position="absolute"
|
|
||||||
top="-12px"
|
|
||||||
right="0"
|
|
||||||
fontSize="10px"
|
|
||||||
className={changeColor}
|
|
||||||
>
|
|
||||||
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'action',
|
|
||||||
width: 40,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_: unknown, record: TableRowData) => {
|
|
||||||
if (record.isSection) return null;
|
|
||||||
return (
|
|
||||||
<Eye
|
|
||||||
size={14}
|
|
||||||
color="#D4AF37"
|
|
||||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [displayData, data, showMetricChart]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="income-statement-table">
|
|
||||||
<style>{tableStyles}</style>
|
|
||||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={tableData}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
rowClassName={(record) => {
|
|
||||||
if (record.isSection) return 'section-header';
|
|
||||||
if (record.isTotal) return 'total-row';
|
|
||||||
if (record.isSubtotal) return 'subtotal-row';
|
|
||||||
return '';
|
|
||||||
}}
|
|
||||||
onRow={(record) => ({
|
|
||||||
onClick: () => {
|
|
||||||
if (!record.isSection) {
|
|
||||||
showMetricChart(record.name, record.key, data, record.path);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
|
||||||
})}
|
|
||||||
locale={{ emptyText: '暂无数据' }}
|
|
||||||
/>
|
|
||||||
</ConfigProvider>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IncomeStatementTable = memo(IncomeStatementTableInner);
|
|
||||||
export default IncomeStatementTable;
|
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* 统一财务表格组件 - Ant Design 黑金主题
|
||||||
|
*
|
||||||
|
* 支持两种表格类型:
|
||||||
|
* - metrics: 财务指标表格(7个分类,扁平结构)
|
||||||
|
* - statement: 财务报表表格(3个报表,分组结构)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, memo } from 'react';
|
||||||
|
import { Box, Text, HStack, Badge as ChakraBadge, Button, Spinner, Center } from '@chakra-ui/react';
|
||||||
|
import { Table, ConfigProvider, Tooltip } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY, getValueByPath, isNegativeIndicator } from '../utils';
|
||||||
|
import type { MetricConfig, MetricSectionConfig } from '../types';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
export type TableType = 'metrics' | 'statement';
|
||||||
|
|
||||||
|
// 数据类型:必须有 period 字段
|
||||||
|
export type FinancialDataItem = { period: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
export interface UnifiedFinancialTableProps {
|
||||||
|
/** 表格类型: metrics=指标表格, statement=报表表格 */
|
||||||
|
type: TableType;
|
||||||
|
/** 数据数组 */
|
||||||
|
data: FinancialDataItem[];
|
||||||
|
/** metrics 类型: 指标分类 key */
|
||||||
|
categoryKey?: string;
|
||||||
|
/** metrics 类型: 分类标题 */
|
||||||
|
categoryTitle?: string;
|
||||||
|
/** metrics 类型: 指标配置数组 */
|
||||||
|
metrics?: MetricConfig[];
|
||||||
|
/** statement 类型: 分组配置数组 */
|
||||||
|
sections?: MetricSectionConfig[];
|
||||||
|
/** 是否隐藏汇总行的分组标题 */
|
||||||
|
hideTotalSectionTitle?: boolean;
|
||||||
|
/** 点击行显示图表回调 */
|
||||||
|
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
|
||||||
|
/** 是否为成长类指标(正负值着色) */
|
||||||
|
isGrowthCategory?: boolean;
|
||||||
|
/** 核心指标 keys(用于 cashflow 表格) */
|
||||||
|
coreMetricKeys?: string[];
|
||||||
|
/** 加载中 */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行数据类型
|
||||||
|
interface TableRowData {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isCore?: boolean;
|
||||||
|
isTotal?: boolean;
|
||||||
|
isSubtotal?: boolean;
|
||||||
|
isSection?: boolean;
|
||||||
|
indent?: number;
|
||||||
|
[period: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_CLASS_NAME = 'unified-financial-table';
|
||||||
|
|
||||||
|
// 扩展样式(支持 positive-value, negative-value)
|
||||||
|
const extendedTableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
||||||
|
.${TABLE_CLASS_NAME} .positive-value {
|
||||||
|
color: #E53E3E;
|
||||||
|
}
|
||||||
|
.${TABLE_CLASS_NAME} .negative-value {
|
||||||
|
color: #48BB78;
|
||||||
|
}
|
||||||
|
.${TABLE_CLASS_NAME} .ant-table-tbody > tr.subtotal-row > td {
|
||||||
|
background: rgba(212, 175, 55, 0.1) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ==================== 组件实现 ====================
|
||||||
|
|
||||||
|
const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
categoryTitle,
|
||||||
|
metrics,
|
||||||
|
sections,
|
||||||
|
hideTotalSectionTitle = true,
|
||||||
|
showMetricChart,
|
||||||
|
isGrowthCategory = false,
|
||||||
|
coreMetricKeys = [],
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
// 加载中状态
|
||||||
|
if (loading && (!Array.isArray(data) || data.length === 0)) {
|
||||||
|
return (
|
||||||
|
<Center py={12}>
|
||||||
|
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据安全检查
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box p={4} textAlign="center" color="gray.400">
|
||||||
|
暂无数据
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制显示列数
|
||||||
|
const maxColumns = type === 'metrics' ? 6 : 8;
|
||||||
|
const displayData = data.slice(0, Math.min(data.length, maxColumns));
|
||||||
|
|
||||||
|
// 构建表格数据
|
||||||
|
const tableData = useMemo(() => {
|
||||||
|
const rows: TableRowData[] = [];
|
||||||
|
|
||||||
|
if (type === 'metrics' && metrics) {
|
||||||
|
// 财务指标表格: 扁平结构
|
||||||
|
metrics.forEach((metric) => {
|
||||||
|
const row: TableRowData = {
|
||||||
|
key: metric.key,
|
||||||
|
name: metric.name,
|
||||||
|
path: metric.path,
|
||||||
|
isCore: metric.isCore || coreMetricKeys.includes(metric.key),
|
||||||
|
};
|
||||||
|
|
||||||
|
displayData.forEach((item) => {
|
||||||
|
const value = getValueByPath<number>(item, metric.path);
|
||||||
|
row[item.period] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
} else if (type === 'statement' && sections) {
|
||||||
|
// 财务报表表格: 分组结构
|
||||||
|
sections.forEach((section) => {
|
||||||
|
// 添加分组标题行(可配置隐藏汇总行标题)
|
||||||
|
const isTotalSection = section.title.includes('总计') || section.title.includes('合计');
|
||||||
|
if (!isTotalSection || !hideTotalSectionTitle) {
|
||||||
|
rows.push({
|
||||||
|
key: `section-${section.key}`,
|
||||||
|
name: section.title,
|
||||||
|
path: '',
|
||||||
|
isSection: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加指标行
|
||||||
|
section.metrics.forEach((metric) => {
|
||||||
|
const row: TableRowData = {
|
||||||
|
key: metric.key,
|
||||||
|
name: metric.name,
|
||||||
|
path: metric.path,
|
||||||
|
isCore: metric.isCore,
|
||||||
|
isTotal: metric.isTotal || isTotalSection,
|
||||||
|
isSubtotal: metric.isSubtotal,
|
||||||
|
indent: metric.isTotal || metric.isSubtotal ? 0 : (metric.name.startsWith(' ') ? 2 : 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
displayData.forEach((item) => {
|
||||||
|
const value = getValueByPath<number>(item, metric.path);
|
||||||
|
row[item.period] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [type, metrics, sections, displayData, hideTotalSectionTitle, coreMetricKeys]);
|
||||||
|
|
||||||
|
// 计算同比变化
|
||||||
|
const calcYoY = (
|
||||||
|
currentValue: number | undefined,
|
||||||
|
currentPeriod: string,
|
||||||
|
path: string
|
||||||
|
): number | null => {
|
||||||
|
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建列定义
|
||||||
|
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||||
|
const cols: ColumnsType<TableRowData> = [
|
||||||
|
// 指标名称列
|
||||||
|
{
|
||||||
|
title: type === 'metrics' ? (categoryTitle || '指标') : '项目',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
fixed: 'left',
|
||||||
|
width: type === 'metrics' ? 200 : 250,
|
||||||
|
render: (name: string, record: TableRowData) => {
|
||||||
|
if (record.isSection) {
|
||||||
|
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<HStack spacing={2} pl={record.indent ? record.indent * 4 : 0}>
|
||||||
|
<Text
|
||||||
|
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : type === 'metrics' ? 'medium' : 'normal'}
|
||||||
|
fontSize={type === 'metrics' ? 'xs' : undefined}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{record.isCore && (
|
||||||
|
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
||||||
|
核心
|
||||||
|
</ChakraBadge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 各期数据列
|
||||||
|
...displayData.map((item) => ({
|
||||||
|
title: (
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
||||||
|
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
dataIndex: item.period,
|
||||||
|
key: item.period,
|
||||||
|
width: type === 'metrics' ? 100 : 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (value: number | undefined, record: TableRowData) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
|
||||||
|
const yoy = calcYoY(value, item.period, record.path);
|
||||||
|
const isNegative = isNegativeIndicator(record.key);
|
||||||
|
|
||||||
|
// 值格式化
|
||||||
|
let formattedValue: string;
|
||||||
|
let valueColorClass = '';
|
||||||
|
|
||||||
|
if (type === 'metrics') {
|
||||||
|
formattedValue = value?.toFixed(2) || '-';
|
||||||
|
// 成长类指标: 正值红色,负值绿色
|
||||||
|
if (isGrowthCategory) {
|
||||||
|
valueColorClass = value !== undefined && value > 0
|
||||||
|
? 'positive-value'
|
||||||
|
: value !== undefined && value < 0
|
||||||
|
? 'negative-value'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 财务报表:使用大数格式化
|
||||||
|
const isEPS = record.key.includes('eps');
|
||||||
|
formattedValue = isEPS ? (value?.toFixed(3) || '-') : formatUtils.formatLargeNumber(value, 0);
|
||||||
|
// 利润表负值着色
|
||||||
|
if (value !== undefined && value < 0) {
|
||||||
|
valueColorClass = 'negative-value';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同比变化颜色(负向指标逻辑反转)
|
||||||
|
const changeColor = isNegative
|
||||||
|
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
||||||
|
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
||||||
|
|
||||||
|
// 显示同比箭头的阈值
|
||||||
|
const yoyThreshold = type === 'metrics' ? 20 : 30;
|
||||||
|
const showYoyArrow = yoy !== null && Math.abs(yoy) > yoyThreshold && !record.isTotal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Text>{record.name}: {type === 'metrics' ? (value?.toFixed(2) || '-') : formatUtils.formatLargeNumber(value)}</Text>
|
||||||
|
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box position="relative">
|
||||||
|
<Text
|
||||||
|
fontSize={type === 'metrics' ? 'xs' : undefined}
|
||||||
|
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}
|
||||||
|
className={valueColorClass || undefined}
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</Text>
|
||||||
|
{showYoyArrow && value !== undefined && Math.abs(value) > 0.01 && (
|
||||||
|
<Text
|
||||||
|
position="absolute"
|
||||||
|
top="-12px"
|
||||||
|
right="0"
|
||||||
|
fontSize="10px"
|
||||||
|
className={changeColor}
|
||||||
|
>
|
||||||
|
{yoy > 0 ? '↑' : '↓'}{type === 'statement' && Math.abs(yoy) < 100 ? `${Math.abs(yoy).toFixed(0)}%` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
// 操作列
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'action',
|
||||||
|
width: type === 'metrics' ? 40 : 80,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_: unknown, record: TableRowData) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
|
||||||
|
if (type === 'metrics') {
|
||||||
|
return (
|
||||||
|
<Eye
|
||||||
|
size={14}
|
||||||
|
color="#D4AF37"
|
||||||
|
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showMetricChart(record.name, record.key, data, record.path);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="#D4AF37"
|
||||||
|
borderColor="#D4AF37"
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.2)' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showMetricChart(record.name, record.key, data, record.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
趋势
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [type, displayData, data, showMetricChart, categoryTitle, isGrowthCategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={TABLE_CLASS_NAME}>
|
||||||
|
<style>{extendedTableStyles}</style>
|
||||||
|
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tableData}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
rowClassName={(record) => {
|
||||||
|
if (record.isSection) return 'section-header';
|
||||||
|
if (record.isTotal) return 'total-row';
|
||||||
|
if (record.isSubtotal) return 'subtotal-row';
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => {
|
||||||
|
if (!record.isSection) {
|
||||||
|
showMetricChart(record.name, record.key, data, record.path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
||||||
|
})}
|
||||||
|
locale={{ emptyText: '暂无数据' }}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnifiedFinancialTable = memo(UnifiedFinancialTableInner);
|
||||||
|
export default UnifiedFinancialTable;
|
||||||
@@ -4,16 +4,15 @@
|
|||||||
|
|
||||||
export { PeriodSelector } from './PeriodSelector';
|
export { PeriodSelector } from './PeriodSelector';
|
||||||
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
|
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
|
||||||
// 保留旧组件导出(向后兼容)
|
|
||||||
export { KeyMetricsOverview } from './KeyMetricsOverview';
|
export { KeyMetricsOverview } from './KeyMetricsOverview';
|
||||||
export { StockInfoHeader } from './StockInfoHeader';
|
export { StockInfoHeader } from './StockInfoHeader';
|
||||||
export { BalanceSheetTable } from './BalanceSheetTable';
|
|
||||||
export { IncomeStatementTable } from './IncomeStatementTable';
|
|
||||||
export { CashflowTable } from './CashflowTable';
|
|
||||||
export { FinancialMetricsTable } from './FinancialMetricsTable';
|
|
||||||
export { MainBusinessAnalysis } from './MainBusinessAnalysis';
|
export { MainBusinessAnalysis } from './MainBusinessAnalysis';
|
||||||
export { IndustryRankingView } from './IndustryRankingView';
|
export { IndustryRankingView } from './IndustryRankingView';
|
||||||
export { StockComparison } from './StockComparison';
|
export { StockComparison } from './StockComparison';
|
||||||
export { ComparisonAnalysis } from './ComparisonAnalysis';
|
export { ComparisonAnalysis } from './ComparisonAnalysis';
|
||||||
export { MetricChartModal } from './MetricChartModal';
|
export { MetricChartModal } from './MetricChartModal';
|
||||||
export type { MetricChartModalProps } from './MetricChartModal';
|
export type { MetricChartModalProps } from './MetricChartModal';
|
||||||
|
export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton';
|
||||||
|
// 统一财务表格组件
|
||||||
|
export { UnifiedFinancialTable } from './UnifiedFinancialTable';
|
||||||
|
export type { UnifiedFinancialTableProps, TableType, FinancialDataItem } from './UnifiedFinancialTable';
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
|
|
||||||
// 通用组件
|
// 通用组件
|
||||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||||
import LoadingState from '../LoadingState';
|
|
||||||
|
|
||||||
// 内部模块导入
|
// 内部模块导入
|
||||||
import { useFinancialData, type DataTypeKey } from './hooks';
|
import { useFinancialData, type DataTypeKey } from './hooks';
|
||||||
@@ -42,6 +41,7 @@ import {
|
|||||||
MainBusinessAnalysis,
|
MainBusinessAnalysis,
|
||||||
ComparisonAnalysis,
|
ComparisonAnalysis,
|
||||||
MetricChartModal,
|
MetricChartModal,
|
||||||
|
FinancialPanoramaSkeleton,
|
||||||
} from './components';
|
} from './components';
|
||||||
import {
|
import {
|
||||||
BalanceSheetTab,
|
BalanceSheetTab,
|
||||||
@@ -172,26 +172,31 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 初始加载显示骨架屏
|
||||||
|
if (loading && !stockInfo) {
|
||||||
|
return (
|
||||||
|
<Container maxW="container.xl" py={5}>
|
||||||
|
<FinancialPanoramaSkeleton />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="container.xl" py={5}>
|
<Container maxW="container.xl" py={5}>
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
||||||
{loading ? (
|
<FinancialOverviewPanel
|
||||||
<LoadingState message="加载财务数据中..." height="300px" variant="skeleton" skeletonRows={6} />
|
stockInfo={stockInfo}
|
||||||
) : (
|
financialMetrics={financialMetrics}
|
||||||
<FinancialOverviewPanel
|
/>
|
||||||
stockInfo={stockInfo}
|
|
||||||
financialMetrics={financialMetrics}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 营收与利润趋势 */}
|
{/* 营收与利润趋势 */}
|
||||||
{!loading && comparison && comparison.length > 0 && (
|
{comparison && comparison.length > 0 && (
|
||||||
<ComparisonAnalysis comparison={comparison} />
|
<ComparisonAnalysis comparison={comparison} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主营业务 */}
|
{/* 主营业务 */}
|
||||||
{!loading && stockInfo && (
|
{stockInfo && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
|
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
|
||||||
主营业务
|
主营业务
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* 资产负债表 Tab
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
|
||||||
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
|
|
||||||
import { BalanceSheetTable } from '../components';
|
|
||||||
import type { BalanceSheetData } from '../types';
|
|
||||||
|
|
||||||
export interface BalanceSheetTabProps {
|
|
||||||
balanceSheet: BalanceSheetData[];
|
|
||||||
loading?: boolean;
|
|
||||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
|
||||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
|
||||||
getCellBackground: (change: number, intensity: number) => string;
|
|
||||||
positiveColor: string;
|
|
||||||
negativeColor: string;
|
|
||||||
bgColor: string;
|
|
||||||
hoverBg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BalanceSheetTabInner: React.FC<BalanceSheetTabProps> = ({
|
|
||||||
balanceSheet,
|
|
||||||
loading,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
}) => {
|
|
||||||
// 加载中状态
|
|
||||||
if (loading && (!Array.isArray(balanceSheet) || balanceSheet.length === 0)) {
|
|
||||||
return (
|
|
||||||
<Center py={12}>
|
|
||||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableProps = {
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<VStack align="stretch" spacing={2} mb={4}>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Heading size="md" color="#D4AF37">资产负债表</Heading>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
显示最近{Math.min(balanceSheet.length, 8)}期
|
|
||||||
</Badge>
|
|
||||||
<Text fontSize="sm" color="gray.400">
|
|
||||||
红涨绿跌 | 同比变化
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<Text fontSize="xs" color="gray.500">
|
|
||||||
提示:表格可横向滚动查看更多数据,点击行查看历史趋势
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
<BalanceSheetTable data={balanceSheet} {...tableProps} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BalanceSheetTab = memo(BalanceSheetTabInner);
|
|
||||||
export default BalanceSheetTab;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* 现金流量表 Tab
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
|
||||||
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
|
|
||||||
import { CashflowTable } from '../components';
|
|
||||||
import type { CashflowData } from '../types';
|
|
||||||
|
|
||||||
export interface CashflowTabProps {
|
|
||||||
cashflow: CashflowData[];
|
|
||||||
loading?: boolean;
|
|
||||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
|
||||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
|
||||||
getCellBackground: (change: number, intensity: number) => string;
|
|
||||||
positiveColor: string;
|
|
||||||
negativeColor: string;
|
|
||||||
bgColor: string;
|
|
||||||
hoverBg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CashflowTabInner: React.FC<CashflowTabProps> = ({
|
|
||||||
cashflow,
|
|
||||||
loading,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
}) => {
|
|
||||||
// 加载中状态
|
|
||||||
if (loading && (!Array.isArray(cashflow) || cashflow.length === 0)) {
|
|
||||||
return (
|
|
||||||
<Center py={12}>
|
|
||||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableProps = {
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<VStack align="stretch" spacing={2} mb={4}>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Heading size="md" color="#D4AF37">现金流量表</Heading>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
显示最近{Math.min(cashflow.length, 8)}期
|
|
||||||
</Badge>
|
|
||||||
<Text fontSize="sm" color="gray.400">
|
|
||||||
红涨绿跌 | 同比变化
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<Text fontSize="xs" color="gray.500">
|
|
||||||
提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
<CashflowTable data={cashflow} {...tableProps} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CashflowTab = memo(CashflowTabInner);
|
|
||||||
export default CashflowTab;
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* 财务指标 Tab
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
|
||||||
import { Spinner, Center } from '@chakra-ui/react';
|
|
||||||
import { FinancialMetricsTable } from '../components';
|
|
||||||
import type { FinancialMetricsData } from '../types';
|
|
||||||
|
|
||||||
export interface FinancialMetricsTabProps {
|
|
||||||
financialMetrics: FinancialMetricsData[];
|
|
||||||
loading?: boolean;
|
|
||||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
|
||||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
|
||||||
getCellBackground: (change: number, intensity: number) => string;
|
|
||||||
positiveColor: string;
|
|
||||||
negativeColor: string;
|
|
||||||
bgColor: string;
|
|
||||||
hoverBg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FinancialMetricsTabInner: React.FC<FinancialMetricsTabProps> = ({
|
|
||||||
financialMetrics,
|
|
||||||
loading,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
}) => {
|
|
||||||
// 加载中状态
|
|
||||||
if (loading && (!Array.isArray(financialMetrics) || financialMetrics.length === 0)) {
|
|
||||||
return (
|
|
||||||
<Center py={12}>
|
|
||||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableProps = {
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FinancialMetricsTab = memo(FinancialMetricsTabInner);
|
|
||||||
export default FinancialMetricsTab;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* 利润表 Tab
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
|
||||||
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
|
|
||||||
import { IncomeStatementTable } from '../components';
|
|
||||||
import type { IncomeStatementData } from '../types';
|
|
||||||
|
|
||||||
export interface IncomeStatementTabProps {
|
|
||||||
incomeStatement: IncomeStatementData[];
|
|
||||||
loading?: boolean;
|
|
||||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
|
||||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
|
||||||
getCellBackground: (change: number, intensity: number) => string;
|
|
||||||
positiveColor: string;
|
|
||||||
negativeColor: string;
|
|
||||||
bgColor: string;
|
|
||||||
hoverBg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IncomeStatementTabInner: React.FC<IncomeStatementTabProps> = ({
|
|
||||||
incomeStatement,
|
|
||||||
loading,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
}) => {
|
|
||||||
// 加载中状态
|
|
||||||
if (loading && (!Array.isArray(incomeStatement) || incomeStatement.length === 0)) {
|
|
||||||
return (
|
|
||||||
<Center py={12}>
|
|
||||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableProps = {
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
getCellBackground,
|
|
||||||
positiveColor,
|
|
||||||
negativeColor,
|
|
||||||
bgColor,
|
|
||||||
hoverBg,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<VStack align="stretch" spacing={2} mb={4}>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Heading size="md" color="#D4AF37">利润表</Heading>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
显示最近{Math.min(incomeStatement.length, 8)}期
|
|
||||||
</Badge>
|
|
||||||
<Text fontSize="sm" color="gray.400">
|
|
||||||
红涨绿跌 | 同比变化
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<Text fontSize="xs" color="gray.500">
|
|
||||||
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const IncomeStatementTab = memo(IncomeStatementTabInner);
|
|
||||||
export default IncomeStatementTab;
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
/**
|
|
||||||
* 财务指标分类 Tab - Ant Design 黑金主题
|
|
||||||
* 接受 categoryKey 显示单个分类的指标表格
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo, memo } from 'react';
|
|
||||||
import { Box, Text, HStack, Badge as ChakraBadge, Spinner, Center } from '@chakra-ui/react';
|
|
||||||
import { Table, ConfigProvider, Tooltip } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { formatUtils } from '@services/financialService';
|
|
||||||
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
|
|
||||||
import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
|
||||||
import type { FinancialMetricsData } from '../types';
|
|
||||||
|
|
||||||
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
|
|
||||||
|
|
||||||
const TABLE_CLASS_NAME = 'metrics-category-table';
|
|
||||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
|
||||||
.${TABLE_CLASS_NAME} .positive-value {
|
|
||||||
color: #E53E3E;
|
|
||||||
}
|
|
||||||
.${TABLE_CLASS_NAME} .negative-value {
|
|
||||||
color: #48BB78;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export interface MetricsCategoryTabProps {
|
|
||||||
categoryKey: CategoryKey;
|
|
||||||
financialMetrics: FinancialMetricsData[];
|
|
||||||
loading?: boolean;
|
|
||||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
|
||||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
|
||||||
getCellBackground: (change: number, intensity: number) => string;
|
|
||||||
positiveColor: string;
|
|
||||||
negativeColor: string;
|
|
||||||
bgColor: string;
|
|
||||||
hoverBg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格行数据类型
|
|
||||||
interface TableRowData {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isCore?: boolean;
|
|
||||||
[period: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MetricsCategoryTabInner: React.FC<MetricsCategoryTabProps> = ({
|
|
||||||
categoryKey,
|
|
||||||
financialMetrics,
|
|
||||||
loading,
|
|
||||||
showMetricChart,
|
|
||||||
calculateYoYChange,
|
|
||||||
}) => {
|
|
||||||
// 加载中状态
|
|
||||||
if (loading && (!Array.isArray(financialMetrics) || financialMetrics.length === 0)) {
|
|
||||||
return (
|
|
||||||
<Center py={12}>
|
|
||||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数组安全检查
|
|
||||||
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box p={4} textAlign="center" color="gray.400">
|
|
||||||
暂无财务指标数据
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxColumns = Math.min(financialMetrics.length, 6);
|
|
||||||
const displayData = financialMetrics.slice(0, maxColumns);
|
|
||||||
const category = FINANCIAL_METRICS_CATEGORIES[categoryKey];
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
return (
|
|
||||||
<Box p={4} textAlign="center" color="gray.400">
|
|
||||||
未找到指标分类配置
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建表格数据
|
|
||||||
const tableData = useMemo(() => {
|
|
||||||
return category.metrics.map((metric) => {
|
|
||||||
const row: TableRowData = {
|
|
||||||
key: metric.key,
|
|
||||||
name: metric.name,
|
|
||||||
path: metric.path,
|
|
||||||
isCore: metric.isCore,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加各期数值
|
|
||||||
displayData.forEach((item) => {
|
|
||||||
const value = getValueByPath<number>(item, metric.path);
|
|
||||||
row[item.period] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
}, [financialMetrics, displayData, category]);
|
|
||||||
|
|
||||||
// 计算同比变化(使用共享函数)
|
|
||||||
const calcYoY = (
|
|
||||||
currentValue: number | undefined,
|
|
||||||
currentPeriod: string,
|
|
||||||
path: string
|
|
||||||
): number | null => {
|
|
||||||
return calculateYoY(financialMetrics, currentValue, currentPeriod, path, getValueByPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建列定义
|
|
||||||
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
|
||||||
const cols: ColumnsType<TableRowData> = [
|
|
||||||
{
|
|
||||||
title: category.title,
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
fixed: 'left',
|
|
||||||
width: 200,
|
|
||||||
render: (name: string, record: TableRowData) => (
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Text fontWeight="medium" fontSize="xs">{name}</Text>
|
|
||||||
{record.isCore && (
|
|
||||||
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
|
|
||||||
核心
|
|
||||||
</ChakraBadge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...displayData.map((item) => ({
|
|
||||||
title: (
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
dataIndex: item.period,
|
|
||||||
key: item.period,
|
|
||||||
width: 100,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (value: number | undefined, record: TableRowData) => {
|
|
||||||
const yoy = calcYoY(value, item.period, record.path);
|
|
||||||
const isNegative = isNegativeIndicator(record.key);
|
|
||||||
|
|
||||||
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
|
|
||||||
const changeColor = isNegative
|
|
||||||
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
|
||||||
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
|
||||||
|
|
||||||
// 成长能力指标特殊处理:正值红色,负值绿色
|
|
||||||
const valueColor = categoryKey === 'growth'
|
|
||||||
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<Box>
|
|
||||||
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
|
|
||||||
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box position="relative">
|
|
||||||
<Text fontSize="xs" className={valueColor || undefined}>
|
|
||||||
{value?.toFixed(2) || '-'}
|
|
||||||
</Text>
|
|
||||||
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
|
|
||||||
<Text
|
|
||||||
position="absolute"
|
|
||||||
top="-12px"
|
|
||||||
right="0"
|
|
||||||
fontSize="10px"
|
|
||||||
className={changeColor}
|
|
||||||
>
|
|
||||||
{yoy > 0 ? '↑' : '↓'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'action',
|
|
||||||
width: 40,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_: unknown, record: TableRowData) => (
|
|
||||||
<Eye
|
|
||||||
size={14}
|
|
||||||
color="#D4AF37"
|
|
||||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showMetricChart(record.name, record.key, financialMetrics, record.path);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}, [displayData, financialMetrics, showMetricChart, category, categoryKey]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box className="metrics-category-table">
|
|
||||||
<style>{tableStyles}</style>
|
|
||||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={tableData}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
onRow={(record) => ({
|
|
||||||
onClick: () => {
|
|
||||||
showMetricChart(record.name, record.key, financialMetrics, record.path);
|
|
||||||
},
|
|
||||||
style: { cursor: 'pointer' },
|
|
||||||
})}
|
|
||||||
locale={{ emptyText: '暂无数据' }}
|
|
||||||
/>
|
|
||||||
</ConfigProvider>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MetricsCategoryTab = memo(MetricsCategoryTabInner);
|
|
||||||
|
|
||||||
// 为每个分类创建预配置的组件(使用 memo)
|
|
||||||
export const ProfitabilityTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
|
||||||
<MetricsCategoryTab categoryKey="profitability" {...props} />
|
|
||||||
));
|
|
||||||
|
|
||||||
export const PerShareTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
|
||||||
<MetricsCategoryTab categoryKey="perShare" {...props} />
|
|
||||||
));
|
|
||||||
|
|
||||||
export const GrowthTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
|
||||||
<MetricsCategoryTab categoryKey="growth" {...props} />
|
|
||||||
));
|
|
||||||
|
|
||||||
export const OperationalTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
|
||||||
<MetricsCategoryTab categoryKey="operational" {...props} />
|
|
||||||
));
|
|
||||||
|
|
||||||
export const SolvencyTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
|
||||||
<MetricsCategoryTab categoryKey="solvency" {...props} />
|
|
||||||
));
|
|
||||||
|
|
||||||
export const ExpenseTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
|
||||||
<MetricsCategoryTab categoryKey="expense" {...props} />
|
|
||||||
));
|
|
||||||
|
|
||||||
export const CashflowMetricsTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
|
||||||
<MetricsCategoryTab categoryKey="cashflow" {...props} />
|
|
||||||
));
|
|
||||||
|
|
||||||
export default MetricsCategoryTab;
|
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* 统一的财务 Tab 组件
|
||||||
|
*
|
||||||
|
* 使用 UnifiedFinancialTable 实现所有 10 个财务表格:
|
||||||
|
* - 7 个财务指标分类 Tab
|
||||||
|
* - 3 个财务报表 Tab
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { UnifiedFinancialTable, type FinancialDataItem } from '../components/UnifiedFinancialTable';
|
||||||
|
import {
|
||||||
|
FINANCIAL_METRICS_CATEGORIES,
|
||||||
|
CURRENT_ASSETS_METRICS,
|
||||||
|
NON_CURRENT_ASSETS_METRICS,
|
||||||
|
TOTAL_ASSETS_METRICS,
|
||||||
|
CURRENT_LIABILITIES_METRICS,
|
||||||
|
NON_CURRENT_LIABILITIES_METRICS,
|
||||||
|
TOTAL_LIABILITIES_METRICS,
|
||||||
|
EQUITY_METRICS,
|
||||||
|
INCOME_STATEMENT_SECTIONS,
|
||||||
|
CASHFLOW_METRICS,
|
||||||
|
} from '../constants';
|
||||||
|
import type { FinancialMetricsData, BalanceSheetData, IncomeStatementData, CashflowData } from '../types';
|
||||||
|
|
||||||
|
// ==================== 通用 Props 类型 ====================
|
||||||
|
|
||||||
|
/** 财务指标 Tab Props */
|
||||||
|
export interface MetricsTabProps {
|
||||||
|
financialMetrics: FinancialMetricsData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资产负债表 Tab Props */
|
||||||
|
export interface BalanceSheetTabProps {
|
||||||
|
balanceSheet: BalanceSheetData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 利润表 Tab Props */
|
||||||
|
export interface IncomeStatementTabProps {
|
||||||
|
incomeStatement: IncomeStatementData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 现金流量表 Tab Props */
|
||||||
|
export interface CashflowTabProps {
|
||||||
|
cashflow: CashflowData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
|
||||||
|
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||||
|
getCellBackground: (change: number, intensity: number) => string;
|
||||||
|
positiveColor: string;
|
||||||
|
negativeColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 财务指标 Tab (7个) ====================
|
||||||
|
|
||||||
|
/** 盈利能力 Tab */
|
||||||
|
export const ProfitabilityTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.profitability;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics as unknown as FinancialDataItem[]}
|
||||||
|
categoryKey="profitability"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ProfitabilityTab.displayName = 'ProfitabilityTab';
|
||||||
|
|
||||||
|
/** 每股指标 Tab */
|
||||||
|
export const PerShareTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.perShare;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics as unknown as FinancialDataItem[]}
|
||||||
|
categoryKey="perShare"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
PerShareTab.displayName = 'PerShareTab';
|
||||||
|
|
||||||
|
/** 成长能力 Tab */
|
||||||
|
export const GrowthTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.growth;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics as unknown as FinancialDataItem[]}
|
||||||
|
categoryKey="growth"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
isGrowthCategory
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
GrowthTab.displayName = 'GrowthTab';
|
||||||
|
|
||||||
|
/** 运营效率 Tab */
|
||||||
|
export const OperationalTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.operational;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics as unknown as FinancialDataItem[]}
|
||||||
|
categoryKey="operational"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
OperationalTab.displayName = 'OperationalTab';
|
||||||
|
|
||||||
|
/** 偿债能力 Tab */
|
||||||
|
export const SolvencyTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.solvency;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics as unknown as FinancialDataItem[]}
|
||||||
|
categoryKey="solvency"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SolvencyTab.displayName = 'SolvencyTab';
|
||||||
|
|
||||||
|
/** 费用率 Tab */
|
||||||
|
export const ExpenseTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.expense;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics as unknown as FinancialDataItem[]}
|
||||||
|
categoryKey="expense"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ExpenseTab.displayName = 'ExpenseTab';
|
||||||
|
|
||||||
|
/** 现金流指标 Tab */
|
||||||
|
export const CashflowMetricsTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.cashflow;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics as unknown as FinancialDataItem[]}
|
||||||
|
categoryKey="cashflow"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CashflowMetricsTab.displayName = 'CashflowMetricsTab';
|
||||||
|
|
||||||
|
// ==================== 财务报表 Tab (3个) ====================
|
||||||
|
|
||||||
|
// 资产负债表分组配置
|
||||||
|
const BALANCE_SHEET_SECTIONS = [
|
||||||
|
CURRENT_ASSETS_METRICS,
|
||||||
|
NON_CURRENT_ASSETS_METRICS,
|
||||||
|
TOTAL_ASSETS_METRICS,
|
||||||
|
CURRENT_LIABILITIES_METRICS,
|
||||||
|
NON_CURRENT_LIABILITIES_METRICS,
|
||||||
|
TOTAL_LIABILITIES_METRICS,
|
||||||
|
EQUITY_METRICS,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 资产负债表 Tab */
|
||||||
|
export const BalanceSheetTab = memo<BalanceSheetTabProps>(({ balanceSheet, loading, showMetricChart }) => (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="statement"
|
||||||
|
data={balanceSheet as unknown as FinancialDataItem[]}
|
||||||
|
sections={BALANCE_SHEET_SECTIONS}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
BalanceSheetTab.displayName = 'BalanceSheetTab';
|
||||||
|
|
||||||
|
/** 利润表 Tab */
|
||||||
|
export const IncomeStatementTab = memo<IncomeStatementTabProps>(({ incomeStatement, loading, showMetricChart }) => (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="statement"
|
||||||
|
data={incomeStatement as unknown as FinancialDataItem[]}
|
||||||
|
sections={INCOME_STATEMENT_SECTIONS}
|
||||||
|
hideTotalSectionTitle={false}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
IncomeStatementTab.displayName = 'IncomeStatementTab';
|
||||||
|
|
||||||
|
// 现金流量表配置(转换为 sections 格式)
|
||||||
|
const CASHFLOW_SECTIONS = [{
|
||||||
|
title: '现金流量',
|
||||||
|
key: 'cashflow',
|
||||||
|
metrics: CASHFLOW_METRICS.map(m => ({
|
||||||
|
...m,
|
||||||
|
isCore: ['operating_net', 'free_cash_flow'].includes(m.key),
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
|
||||||
|
/** 现金流量表 Tab */
|
||||||
|
export const CashflowTab = memo<CashflowTabProps>(({ cashflow, loading, showMetricChart }) => (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="statement"
|
||||||
|
data={cashflow as unknown as FinancialDataItem[]}
|
||||||
|
sections={CASHFLOW_SECTIONS}
|
||||||
|
hideTotalSectionTitle
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CashflowTab.displayName = 'CashflowTab';
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Tab 组件统一导出
|
* Tab 组件统一导出
|
||||||
|
*
|
||||||
|
* 使用 UnifiedTabs 实现的 10 个财务表格 Tab
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 三大财务报表
|
// 统一 Tab 组件导出
|
||||||
export { default as BalanceSheetTab } from './BalanceSheetTab';
|
|
||||||
export { default as IncomeStatementTab } from './IncomeStatementTab';
|
|
||||||
export { default as CashflowTab } from './CashflowTab';
|
|
||||||
|
|
||||||
// 财务指标分类 tabs
|
|
||||||
export {
|
export {
|
||||||
|
// 7 个财务指标 Tab
|
||||||
ProfitabilityTab,
|
ProfitabilityTab,
|
||||||
PerShareTab,
|
PerShareTab,
|
||||||
GrowthTab,
|
GrowthTab,
|
||||||
@@ -16,13 +14,20 @@ export {
|
|||||||
SolvencyTab,
|
SolvencyTab,
|
||||||
ExpenseTab,
|
ExpenseTab,
|
||||||
CashflowMetricsTab,
|
CashflowMetricsTab,
|
||||||
} from './MetricsCategoryTab';
|
// 3 个财务报表 Tab
|
||||||
|
BalanceSheetTab,
|
||||||
|
IncomeStatementTab,
|
||||||
|
CashflowTab,
|
||||||
|
} from './UnifiedTabs';
|
||||||
|
|
||||||
// 旧的综合财务指标 tab(保留兼容)
|
// 类型导出
|
||||||
export { default as FinancialMetricsTab } from './FinancialMetricsTab';
|
export type {
|
||||||
|
MetricsTabProps,
|
||||||
|
BalanceSheetTabProps,
|
||||||
|
IncomeStatementTabProps,
|
||||||
|
CashflowTabProps,
|
||||||
|
} from './UnifiedTabs';
|
||||||
|
|
||||||
export type { BalanceSheetTabProps } from './BalanceSheetTab';
|
// 兼容旧的类型别名
|
||||||
export type { IncomeStatementTabProps } from './IncomeStatementTab';
|
export type { MetricsTabProps as MetricsCategoryTabProps } from './UnifiedTabs';
|
||||||
export type { CashflowTabProps } from './CashflowTab';
|
export type { MetricsTabProps as FinancialMetricsTabProps } from './UnifiedTabs';
|
||||||
export type { FinancialMetricsTabProps } from './FinancialMetricsTab';
|
|
||||||
export type { MetricsCategoryTabProps } from './MetricsCategoryTab';
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 盈利预测骨架屏组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
SkeletonText,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
// 黑金主题配色
|
||||||
|
const SKELETON_COLORS = {
|
||||||
|
startColor: 'rgba(26, 32, 44, 0.6)',
|
||||||
|
endColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图表卡片骨架屏
|
||||||
|
*/
|
||||||
|
const ChartCardSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<Skeleton height="20px" width="100px" mb={4} {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="200px" borderRadius="md" {...SKELETON_COLORS} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
ChartCardSkeleton.displayName = 'ChartCardSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表格骨架屏
|
||||||
|
*/
|
||||||
|
const TableSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<Skeleton height="20px" width="120px" mb={4} {...SKELETON_COLORS} />
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{/* 表头 */}
|
||||||
|
<Skeleton height="40px" {...SKELETON_COLORS} />
|
||||||
|
{/* 表格行 */}
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} height="36px" {...SKELETON_COLORS} />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
TableSkeleton.displayName = 'TableSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 盈利预测完整骨架屏
|
||||||
|
*/
|
||||||
|
const ForecastSkeleton: React.FC = memo(() => (
|
||||||
|
<Box>
|
||||||
|
{/* 图表区域 - 3列布局 */}
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||||||
|
<ChartCardSkeleton />
|
||||||
|
<ChartCardSkeleton />
|
||||||
|
<ChartCardSkeleton />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 详细数据表格 */}
|
||||||
|
<Box mt={4}>
|
||||||
|
<TableSkeleton />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
ForecastSkeleton.displayName = 'ForecastSkeleton';
|
||||||
|
|
||||||
|
export { ForecastSkeleton };
|
||||||
|
export default ForecastSkeleton;
|
||||||
@@ -9,3 +9,4 @@ export { default as IncomeProfitGrowthChart } from './IncomeProfitGrowthChart';
|
|||||||
export { default as EpsChart } from './EpsChart';
|
export { default as EpsChart } from './EpsChart';
|
||||||
export { default as PePegChart } from './PePegChart';
|
export { default as PePegChart } from './PePegChart';
|
||||||
export { default as DetailTable } from './DetailTable';
|
export { default as DetailTable } from './DetailTable';
|
||||||
|
export { ForecastSkeleton } from './ForecastSkeleton';
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ import {
|
|||||||
EpsChart,
|
EpsChart,
|
||||||
PePegChart,
|
PePegChart,
|
||||||
DetailTable,
|
DetailTable,
|
||||||
|
ForecastSkeleton,
|
||||||
} from './components';
|
} from './components';
|
||||||
import LoadingState from '../LoadingState';
|
|
||||||
import type { ForecastReportProps } from './types';
|
import type { ForecastReportProps } from './types';
|
||||||
|
|
||||||
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode }) => {
|
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode }) => {
|
||||||
const { data, isLoading, error, refetch } = useForecastData(stockCode);
|
const { data, isLoading, error, refetch } = useForecastData(stockCode);
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态 - 显示骨架屏
|
||||||
if (isLoading && !data) {
|
if (isLoading && !data) {
|
||||||
return <LoadingState message="加载盈利预测数据中..." height="300px" />;
|
return <ForecastSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误状态
|
// 错误状态
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 立即清空旧数据,触发骨架屏显示
|
||||||
|
setQuoteData(null);
|
||||||
|
setBasicInfo(null);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
* - Tab 配置
|
* - Tab 配置
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy } from 'react';
|
import React, { lazy } from 'react';
|
||||||
import { Building2, Brain, TrendingUp, Wallet, FileBarChart, Newspaper } from 'lucide-react';
|
import { Building2, Brain, TrendingUp, Wallet, FileBarChart, Newspaper } from 'lucide-react';
|
||||||
import type { CompanyTheme, TabConfig } from './types';
|
import type { CompanyTheme, TabConfig } from './types';
|
||||||
|
|
||||||
|
// 骨架屏组件(同步导入,用于 Suspense fallback)
|
||||||
|
import { FinancialPanoramaSkeleton } from './components/FinancialPanorama/components';
|
||||||
|
import { ForecastSkeleton } from './components/ForecastReport/components';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 黑金主题配置
|
// 黑金主题配置
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -43,7 +47,7 @@ export const THEME: CompanyTheme = {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
const CompanyOverview = lazy(() =>
|
const CompanyOverview = lazy(() =>
|
||||||
import(/* webpackChunkName: "company-overview" */ './components/CompanyOverview')
|
import(/* webpackChunkName: "company-overview" */ './components/CompanyOverview/BasicInfoTab')
|
||||||
);
|
);
|
||||||
const DeepAnalysis = lazy(() =>
|
const DeepAnalysis = lazy(() =>
|
||||||
import(/* webpackChunkName: "company-deep-analysis" */ './components/DeepAnalysis')
|
import(/* webpackChunkName: "company-deep-analysis" */ './components/DeepAnalysis')
|
||||||
@@ -89,12 +93,14 @@ export const TAB_CONFIG: TabConfig[] = [
|
|||||||
name: '财务全景',
|
name: '财务全景',
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
component: FinancialPanorama,
|
component: FinancialPanorama,
|
||||||
|
fallback: React.createElement(FinancialPanoramaSkeleton),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'forecast',
|
key: 'forecast',
|
||||||
name: '盈利预测',
|
name: '盈利预测',
|
||||||
icon: FileBarChart,
|
icon: FileBarChart,
|
||||||
component: ForecastReport,
|
component: ForecastReport,
|
||||||
|
fallback: React.createElement(ForecastSkeleton),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tracking',
|
key: 'tracking',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Company 页面类型定义
|
* Company 页面类型定义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
@@ -36,6 +36,8 @@ export interface TabConfig {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: LucideIcon | IconType | ComponentType;
|
icon: LucideIcon | IconType | ComponentType;
|
||||||
component: ComponentType<TabComponentProps>;
|
component: ComponentType<TabComponentProps>;
|
||||||
|
/** 自定义 Suspense fallback(如骨架屏) */
|
||||||
|
fallback?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabComponentProps {
|
export interface TabComponentProps {
|
||||||
|
|||||||
Reference in New Issue
Block a user