refactor: CompanyOverview 组件按 Tab 拆分为独立子组件

将 2682 行的大型组件拆分为 4 个模块化文件:
- index.js (~550行): 状态管理 + 数据加载 + Tab 容器
- DeepAnalysisTab.js (~1800行): 深度分析 Tab(核心定位、竞争力、产业链)
- BasicInfoTab.js (~940行): 基本信息 Tab(股权结构、管理团队、公告)
- NewsEventsTab.js (~540行): 新闻动态 Tab(事件列表 + 分页)

重构内容:
- 提取 8 个内部子组件到对应 Tab 文件
- 修复 useColorModeValue 在 map 回调中调用的 hooks 规则违规
- 清理未使用的 imports
- 完善公告详情模态框(补全 ModalFooter)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-09 17:11:42 +08:00
parent c1e10e6205
commit 18c83237e2
4 changed files with 3539 additions and 2392 deletions

View File

@@ -0,0 +1,941 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab.js
// 基本信息 Tab - 股权结构、管理团队、公司公告、分支机构、工商信息
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Icon,
Card,
CardBody,
CardHeader,
SimpleGrid,
Avatar,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Tag,
Tooltip,
Divider,
Center,
Code,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Stat,
StatLabel,
StatNumber,
StatHelpText,
IconButton,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import {
FaShareAlt,
FaUserTie,
FaBullhorn,
FaSitemap,
FaInfoCircle,
FaCrown,
FaChartPie,
FaUsers,
FaChartLine,
FaArrowUp,
FaArrowDown,
FaChartBar,
FaBuilding,
FaGlobe,
FaShieldAlt,
FaBriefcase,
FaCircle,
FaEye,
FaVenusMars,
FaGraduationCap,
FaPassport,
FaCalendarAlt,
} from "react-icons/fa";
// 格式化工具函数
const formatUtils = {
formatPercentage: (value) => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
},
formatNumber: (value) => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}`;
}
return value.toLocaleString();
},
formatShares: (value) => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
},
formatDate: (dateStr) => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
},
};
// 股东类型标签组件
const ShareholderTypeBadge = ({ type }) => {
const typeConfig = {
基金: { color: "blue", icon: FaChartBar },
个人: { color: "green", icon: FaUserTie },
法人: { color: "purple", icon: FaBuilding },
QFII: { color: "orange", icon: FaGlobe },
社保: { color: "red", icon: FaShieldAlt },
保险: { color: "teal", icon: FaShieldAlt },
信托: { color: "cyan", icon: FaBriefcase },
券商: { color: "pink", icon: FaChartLine },
};
const config = Object.entries(typeConfig).find(([key]) =>
type?.includes(key)
)?.[1] || { color: "gray", icon: FaCircle };
return (
<Badge colorScheme={config.color} size="sm">
<Icon as={config.icon} mr={1} boxSize={3} />
{type}
</Badge>
);
};
/**
* 基本信息 Tab 组件
*
* Props:
* - basicInfo: 公司基本信息
* - actualControl: 实际控制人数组
* - concentration: 股权集中度数组
* - topShareholders: 前十大股东数组
* - topCirculationShareholders: 前十大流通股东数组
* - management: 管理层数组
* - announcements: 公告列表数组
* - branches: 分支机构数组
* - disclosureSchedule: 披露日程数组
* - cardBg: 卡片背景色
* - onAnnouncementClick: 公告点击回调 (announcement) => void
*/
const BasicInfoTab = ({
basicInfo,
actualControl = [],
concentration = [],
topShareholders = [],
topCirculationShareholders = [],
management = [],
announcements = [],
branches = [],
disclosureSchedule = [],
cardBg,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = React.useState(null);
// 管理层职位分类
const getManagementByCategory = () => {
const categories = {
高管: [],
董事: [],
监事: [],
其他: [],
};
management.forEach((person) => {
if (
person.position_category === "高管" ||
person.position_name?.includes("总")
) {
categories["高管"].push(person);
} else if (
person.position_category === "董事" ||
person.position_name?.includes("董事")
) {
categories["董事"].push(person);
} else if (
person.position_category === "监事" ||
person.position_name?.includes("监事")
) {
categories["监事"].push(person);
} else {
categories["其他"].push(person);
}
});
return categories;
};
// 计算股权集中度变化
const getConcentrationTrend = () => {
const grouped = {};
concentration.forEach((item) => {
if (!grouped[item.end_date]) {
grouped[item.end_date] = {};
}
grouped[item.end_date][item.stat_item] = item;
});
return Object.entries(grouped)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 5);
};
// 处理公告点击
const handleAnnouncementClick = (announcement) => {
setSelectedAnnouncement(announcement);
onOpen();
};
return (
<>
<Card bg={cardBg} shadow="md">
<CardBody>
<Tabs variant="enclosed" colorScheme="blue">
<TabList flexWrap="wrap">
<Tab>
<Icon as={FaShareAlt} mr={2} />
股权结构
</Tab>
<Tab>
<Icon as={FaUserTie} mr={2} />
管理团队
</Tab>
<Tab>
<Icon as={FaBullhorn} mr={2} />
公司公告
</Tab>
<Tab>
<Icon as={FaSitemap} mr={2} />
分支机构
</Tab>
<Tab>
<Icon as={FaInfoCircle} mr={2} />
工商信息
</Tab>
</TabList>
<TabPanels>
{/* 股权结构标签页 */}
<TabPanel>
<VStack spacing={6} align="stretch">
{actualControl.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaCrown} color="gold" boxSize={5} />
<Heading size="sm">实际控制人</Heading>
</HStack>
<Card variant="outline">
<CardBody>
<HStack justify="space-between">
<VStack align="start">
<Text fontWeight="bold" fontSize="lg">
{actualControl[0].actual_controller_name}
</Text>
<HStack>
<Badge colorScheme="purple">
{actualControl[0].control_type}
</Badge>
<Text fontSize="sm" color="gray.500">
截至{" "}
{formatUtils.formatDate(
actualControl[0].end_date
)}
</Text>
</HStack>
</VStack>
<Stat textAlign="right">
<StatLabel>控制比例</StatLabel>
<StatNumber color="purple.500">
{formatUtils.formatPercentage(
actualControl[0].holding_ratio
)}
</StatNumber>
<StatHelpText>
{formatUtils.formatShares(
actualControl[0].holding_shares
)}
</StatHelpText>
</Stat>
</HStack>
</CardBody>
</Card>
</Box>
)}
{concentration.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaChartPie} color="blue.500" boxSize={5} />
<Heading size="sm">股权集中度</Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{getConcentrationTrend()
.slice(0, 1)
.map(([date, items]) => (
<Card key={date} variant="outline">
<CardHeader pb={2}>
<Text fontSize="sm" color="gray.500">
{formatUtils.formatDate(date)}
</Text>
</CardHeader>
<CardBody pt={2}>
<VStack spacing={3} align="stretch">
{Object.entries(items).map(([key, item]) => (
<HStack key={key} justify="space-between">
<Text fontSize="sm">
{item.stat_item}
</Text>
<HStack>
<Text
fontWeight="bold"
color="blue.500"
>
{formatUtils.formatPercentage(
item.holding_ratio
)}
</Text>
{item.ratio_change && (
<Badge
colorScheme={
item.ratio_change > 0
? "red"
: "green"
}
>
<Icon
as={
item.ratio_change > 0
? FaArrowUp
: FaArrowDown
}
mr={1}
boxSize={3}
/>
{Math.abs(
item.ratio_change
).toFixed(2)}
%
</Badge>
)}
</HStack>
</HStack>
))}
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)}
{topShareholders.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaUsers} color="green.500" boxSize={5} />
<Heading size="sm">十大股东</Heading>
<Badge>
{formatUtils.formatDate(topShareholders[0].end_date)}
</Badge>
</HStack>
<TableContainer>
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th>排名</Th>
<Th>股东名称</Th>
<Th>股东类型</Th>
<Th isNumeric>持股数量</Th>
<Th isNumeric>持股比例</Th>
<Th>股份性质</Th>
</Tr>
</Thead>
<Tbody>
{topShareholders
.slice(0, 10)
.map((shareholder, idx) => (
<Tr key={idx}>
<Td>
<Badge
colorScheme={idx < 3 ? "red" : "gray"}
>
{shareholder.shareholder_rank}
</Badge>
</Td>
<Td>
<Tooltip
label={shareholder.shareholder_name}
>
<Text noOfLines={1} maxW="200px">
{shareholder.shareholder_name}
</Text>
</Tooltip>
</Td>
<Td>
<ShareholderTypeBadge
type={shareholder.shareholder_type}
/>
</Td>
<Td isNumeric fontWeight="medium">
{formatUtils.formatShares(
shareholder.holding_shares
)}
</Td>
<Td isNumeric>
<Text color="blue.500" fontWeight="bold">
{formatUtils.formatPercentage(
shareholder.total_share_ratio
)}
</Text>
</Td>
<Td>
<Badge size="sm" variant="outline">
{shareholder.share_nature || "流通股"}
</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
)}
{topCirculationShareholders.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaChartLine} color="purple.500" boxSize={5} />
<Heading size="sm">十大流通股东</Heading>
<Badge>
{formatUtils.formatDate(
topCirculationShareholders[0].end_date
)}
</Badge>
</HStack>
<TableContainer>
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th>排名</Th>
<Th>股东名称</Th>
<Th>股东类型</Th>
<Th isNumeric>持股数量</Th>
<Th isNumeric>流通股比例</Th>
</Tr>
</Thead>
<Tbody>
{topCirculationShareholders
.slice(0, 10)
.map((shareholder, idx) => (
<Tr key={idx}>
<Td>
<Badge
colorScheme={idx < 3 ? "orange" : "gray"}
>
{shareholder.shareholder_rank}
</Badge>
</Td>
<Td>
<Tooltip
label={shareholder.shareholder_name}
>
<Text noOfLines={1} maxW="250px">
{shareholder.shareholder_name}
</Text>
</Tooltip>
</Td>
<Td>
<ShareholderTypeBadge
type={shareholder.shareholder_type}
/>
</Td>
<Td isNumeric fontWeight="medium">
{formatUtils.formatShares(
shareholder.holding_shares
)}
</Td>
<Td isNumeric>
<Text color="purple.500" fontWeight="bold">
{formatUtils.formatPercentage(
shareholder.circulation_share_ratio
)}
</Text>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
)}
</VStack>
</TabPanel>
{/* 管理团队标签页 */}
<TabPanel>
<VStack spacing={6} align="stretch">
{Object.entries(getManagementByCategory()).map(
([category, people]) =>
people.length > 0 && (
<Box key={category}>
<HStack mb={4}>
<Icon
as={
category === "高管"
? FaUserTie
: category === "董事"
? FaCrown
: category === "监事"
? FaEye
: FaUsers
}
color={
category === "高管"
? "blue.500"
: category === "董事"
? "purple.500"
: category === "监事"
? "green.500"
: "gray.500"
}
boxSize={5}
/>
<Heading size="sm">{category}</Heading>
<Badge>{people.length}</Badge>
</HStack>
<SimpleGrid
columns={{ base: 1, md: 2, lg: 3 }}
spacing={4}
>
{people.map((person, idx) => (
<Card key={idx} variant="outline" size="sm">
<CardBody>
<HStack spacing={3} align="start">
<Avatar
name={person.name}
size="md"
bg={
category === "高管"
? "blue.500"
: category === "董事"
? "purple.500"
: category === "监事"
? "green.500"
: "gray.500"
}
/>
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold">
{person.name}
</Text>
{person.gender && (
<Icon
as={FaVenusMars}
color={
person.gender === "男"
? "blue.400"
: "pink.400"
}
boxSize={3}
/>
)}
</HStack>
<Text fontSize="sm" color="blue.600">
{person.position_name}
</Text>
<HStack spacing={2} flexWrap="wrap">
{person.education && (
<Tag size="sm" variant="subtle">
<Icon
as={FaGraduationCap}
mr={1}
boxSize={3}
/>
{person.education}
</Tag>
)}
{person.birth_year && (
<Tag size="sm" variant="subtle">
{new Date().getFullYear() -
parseInt(person.birth_year)}
</Tag>
)}
{person.nationality &&
person.nationality !== "中国" && (
<Tag size="sm" colorScheme="orange">
<Icon
as={FaPassport}
mr={1}
boxSize={3}
/>
{person.nationality}
</Tag>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
任职日期
{formatUtils.formatDate(
person.start_date
)}
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)
)}
</VStack>
</TabPanel>
{/* 公司公告标签页 */}
<TabPanel>
<VStack spacing={4} align="stretch">
{disclosureSchedule.length > 0 && (
<Box>
<HStack mb={3}>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="bold">财报披露日程</Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.slice(0, 4).map((schedule, idx) => (
<Card
key={idx}
variant="outline"
size="sm"
bg={
schedule.is_disclosed ? "green.50" : "orange.50"
}
>
<CardBody p={3}>
<VStack spacing={1}>
<Badge
colorScheme={
schedule.is_disclosed ? "green" : "orange"
}
>
{schedule.report_name}
</Badge>
<Text fontSize="sm" fontWeight="bold">
{schedule.is_disclosed ? "已披露" : "预计"}
</Text>
<Text fontSize="xs">
{formatUtils.formatDate(
schedule.is_disclosed
? schedule.actual_date
: schedule.latest_scheduled_date
)}
</Text>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)}
<Divider />
<Box>
<HStack mb={3}>
<Icon as={FaBullhorn} color="blue.500" />
<Text fontWeight="bold">最新公告</Text>
</HStack>
<VStack spacing={2} align="stretch">
{announcements.map((announcement, idx) => (
<Card
key={idx}
variant="outline"
size="sm"
cursor="pointer"
onClick={() => handleAnnouncementClick(announcement)}
_hover={{ bg: "gray.50" }}
>
<CardBody p={3}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Badge size="sm" colorScheme="blue">
{announcement.info_type || "公告"}
</Badge>
<Text fontSize="xs" color="gray.500">
{formatUtils.formatDate(
announcement.announce_date
)}
</Text>
</HStack>
<Text
fontSize="sm"
fontWeight="medium"
noOfLines={1}
>
{announcement.title}
</Text>
</VStack>
<HStack>
{announcement.format && (
<Tag size="sm" variant="subtle">
{announcement.format}
</Tag>
)}
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
onClick={(e) => {
e.stopPropagation();
window.open(announcement.url, "_blank");
}}
/>
</HStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</Box>
</VStack>
</TabPanel>
{/* 分支机构标签页 */}
<TabPanel>
{branches.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{branches.map((branch, idx) => (
<Card key={idx} variant="outline">
<CardBody>
<VStack align="start" spacing={3}>
<HStack justify="space-between" w="full">
<Text fontWeight="bold">
{branch.branch_name}
</Text>
<Badge
colorScheme={
branch.business_status === "存续"
? "green"
: "red"
}
>
{branch.business_status}
</Badge>
</HStack>
<SimpleGrid columns={2} spacing={2} w="full">
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
注册资本
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.register_capital || "-"}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
法人代表
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.legal_person || "-"}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
成立日期
</Text>
<Text fontSize="sm" fontWeight="medium">
{formatUtils.formatDate(branch.register_date)}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
关联企业
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.related_company_count || 0}
</Text>
</VStack>
</SimpleGrid>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
) : (
<Center h="200px">
<VStack>
<Icon as={FaSitemap} boxSize={12} color="gray.300" />
<Text color="gray.500">暂无分支机构信息</Text>
</VStack>
</Center>
)}
</TabPanel>
{/* 工商信息标签页 */}
<TabPanel>
{basicInfo && (
<VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3}>
工商信息
</Heading>
<VStack align="start" spacing={2}>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
统一信用代码
</Text>
<Code fontSize="xs">{basicInfo.credit_code}</Code>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
公司规模
</Text>
<Text fontSize="sm">{basicInfo.company_size}</Text>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
注册地址
</Text>
<Text fontSize="sm" noOfLines={2}>
{basicInfo.reg_address}
</Text>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
办公地址
</Text>
<Text fontSize="sm" noOfLines={2}>
{basicInfo.office_address}
</Text>
</HStack>
</VStack>
</Box>
<Box>
<Heading size="sm" mb={3}>
服务机构
</Heading>
<VStack align="start" spacing={2}>
<Box>
<Text fontSize="sm" color="gray.600">
会计师事务所
</Text>
<Text fontSize="sm" fontWeight="medium">
{basicInfo.accounting_firm}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
律师事务所
</Text>
<Text fontSize="sm" fontWeight="medium">
{basicInfo.law_firm}
</Text>
</Box>
</VStack>
</Box>
</SimpleGrid>
<Divider />
<Box>
<Heading size="sm" mb={3}>
主营业务
</Heading>
<Text fontSize="sm" lineHeight="tall">
{basicInfo.main_business}
</Text>
</Box>
<Box>
<Heading size="sm" mb={3}>
经营范围
</Heading>
<Text fontSize="sm" lineHeight="tall" color="gray.700">
{basicInfo.business_scope}
</Text>
</Box>
</VStack>
)}
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
{/* 公告详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<VStack align="start" spacing={1}>
<Text>{selectedAnnouncement?.title}</Text>
<HStack>
<Badge colorScheme="blue">
{selectedAnnouncement?.info_type || "公告"}
</Badge>
<Text fontSize="sm" color="gray.500">
{formatUtils.formatDate(selectedAnnouncement?.announce_date)}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="start" spacing={3}>
<Text fontSize="sm">
文件格式{selectedAnnouncement?.format || "-"}
</Text>
<Text fontSize="sm">
文件大小{selectedAnnouncement?.file_size || "-"} KB
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
mr={3}
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
>
查看原文
</Button>
<Button variant="ghost" onClick={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default BasicInfoTab;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,541 @@
// src/views/Company/components/CompanyOverview/NewsEventsTab.js
// 新闻动态 Tab - 相关新闻事件列表 + 分页
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
Button,
Input,
InputGroup,
InputLeftElement,
Tag,
Center,
Spinner,
useColorModeValue,
} 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";
/**
* 新闻动态 Tab 组件
*
* Props:
* - newsEvents: 新闻事件列表数组
* - newsLoading: 加载状态
* - newsPagination: 分页信息 { page, per_page, total, pages, has_next, has_prev }
* - searchQuery: 搜索关键词
* - onSearchChange: 搜索输入回调 (value) => void
* - onSearch: 搜索提交回调 () => void
* - onPageChange: 分页回调 (page) => void
* - cardBg: 卡片背景色
*/
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,
}) => {
// 颜色模式值需要在组件顶层调用
const hoverBg = useColorModeValue("gray.50", "gray.700");
// 事件类型图标映射
const getEventTypeIcon = (eventType) => {
const iconMap = {
企业公告: FaBullhorn,
政策: FaGavel,
技术突破: FaFlask,
企业融资: FaDollarSign,
政策监管: FaShieldAlt,
政策动态: FaFileAlt,
行业事件: FaIndustry,
};
return iconMap[eventType] || FaNewspaper;
};
// 重要性颜色映射
const getImportanceColor = (importance) => {
const colorMap = {
S: "red",
A: "orange",
B: "yellow",
C: "green",
};
return 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="gray.400">
...
</Text>
);
}
for (let i = startPage; i <= endPage; i++) {
pageButtons.push(
<Button
key={i}
size="sm"
variant={i === currentPage ? "solid" : "outline"}
colorScheme={i === currentPage ? "blue" : "gray"}
onClick={() => handlePageChange(i)}
isDisabled={newsLoading}
>
{i}
</Button>
);
}
// 如果结束页小于总页数,显示省略号
if (endPage < totalPages) {
pageButtons.push(
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
...
</Text>
);
}
return pageButtons;
};
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg} shadow="md">
<CardBody>
<VStack spacing={4} align="stretch">
{/* 搜索框和统计信息 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack flex={1} minW="300px">
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
placeholder="搜索相关新闻..."
value={searchQuery}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
/>
</InputGroup>
<Button
colorScheme="blue"
onClick={handleSearchSubmit}
isLoading={newsLoading}
minW="80px"
>
搜索
</Button>
</HStack>
{newsPagination.total > 0 && (
<HStack spacing={2}>
<Icon as={FaNewspaper} color="blue.500" />
<Text fontSize="sm" color="gray.600">
共找到{" "}
<Text as="span" fontWeight="bold" color="blue.600">
{newsPagination.total}
</Text>{" "}
条新闻
</Text>
</HStack>
)}
</HStack>
<div id="news-list-top" />
{/* 新闻列表 */}
{newsLoading ? (
<Center h="400px">
<VStack spacing={3}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.600">正在加载新闻...</Text>
</VStack>
</Center>
) : newsEvents.length > 0 ? (
<>
<VStack spacing={3} align="stretch">
{newsEvents.map((event, idx) => {
const importanceColor = getImportanceColor(
event.importance
);
const eventTypeIcon = getEventTypeIcon(event.event_type);
return (
<Card
key={event.id || idx}
variant="outline"
_hover={{
bg: hoverBg,
shadow: "md",
borderColor: "blue.300",
}}
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="blue.500"
boxSize={5}
/>
<Text
fontWeight="bold"
fontSize="lg"
lineHeight="1.3"
>
{event.title}
</Text>
</HStack>
{/* 标签栏 */}
<HStack spacing={2} flexWrap="wrap">
{event.importance && (
<Badge
colorScheme={importanceColor}
variant="solid"
px={2}
>
{event.importance}
</Badge>
)}
{event.event_type && (
<Badge colorScheme="blue" variant="outline">
{event.event_type}
</Badge>
)}
{event.invest_score && (
<Badge
colorScheme="purple"
variant="subtle"
>
投资分: {event.invest_score}
</Badge>
)}
{event.keywords && event.keywords.length > 0 && (
<>
{event.keywords
.slice(0, 4)
.map((keyword, kidx) => (
<Tag
key={kidx}
size="sm"
colorScheme="cyan"
variant="subtle"
>
{typeof keyword === "string"
? keyword
: keyword?.concept ||
keyword?.name ||
"未知"}
</Tag>
))}
</>
)}
</HStack>
</VStack>
{/* 右侧信息栏 */}
<VStack align="end" spacing={1} minW="100px">
<Text fontSize="xs" color="gray.500">
{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="gray.400"
/>
<Text fontSize="xs" color="gray.500">
{event.view_count}
</Text>
</HStack>
)}
{event.hot_score !== undefined && (
<HStack spacing={1}>
<Icon
as={FaFire}
boxSize={3}
color="orange.400"
/>
<Text fontSize="xs" color="gray.500">
{event.hot_score.toFixed(1)}
</Text>
</HStack>
)}
</HStack>
{event.creator && (
<Text fontSize="xs" color="gray.400">
@{event.creator.username}
</Text>
)}
</VStack>
</HStack>
{/* 描述 */}
{event.description && (
<Text
fontSize="sm"
color="gray.700"
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="gray.200"
>
<HStack spacing={6} flexWrap="wrap">
<HStack spacing={1}>
<Icon
as={FaChartLine}
boxSize={3}
color="gray.500"
/>
<Text
fontSize="xs"
color="gray.500"
fontWeight="medium"
>
相关涨跌:
</Text>
</HStack>
{event.related_avg_chg !== null &&
event.related_avg_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
平均
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_avg_chg > 0
? "red.500"
: "green.500"
}
>
{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="gray.500">
最大
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_max_chg > 0
? "red.500"
: "green.500"
}
>
{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="gray.500">
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_week_chg > 0
? "red.500"
: "green.500"
}
>
{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="gray.600">
{newsPagination.page} / {newsPagination.pages}
</Text>
{/* 分页按钮 */}
<HStack spacing={2}>
<Button
size="sm"
onClick={() => handlePageChange(1)}
isDisabled={!newsPagination.has_prev || newsLoading}
leftIcon={<Icon as={FaChevronLeft} />}
>
首页
</Button>
<Button
size="sm"
onClick={() =>
handlePageChange(newsPagination.page - 1)
}
isDisabled={!newsPagination.has_prev || newsLoading}
>
上一页
</Button>
{/* 页码按钮 */}
{renderPaginationButtons()}
<Button
size="sm"
onClick={() =>
handlePageChange(newsPagination.page + 1)
}
isDisabled={!newsPagination.has_next || newsLoading}
>
下一页
</Button>
<Button
size="sm"
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="gray.300" />
<Text color="gray.500" fontSize="lg" fontWeight="medium">
暂无相关新闻
</Text>
<Text fontSize="sm" color="gray.400">
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
</Text>
</VStack>
</Center>
)}
</VStack>
</CardBody>
</Card>
</VStack>
);
};
export default NewsEventsTab;

File diff suppressed because it is too large Load Diff