Compare commits

...

19 Commits

Author SHA1 Message Date
zdl
3382dd1036 feat: UI调整 2025-12-10 10:09:24 +08:00
zdl
9423094af2 pref: 移除 useColorModeValue
UI调整
2025-12-09 19:26:52 +08:00
zdl
4f38505a80 style: StockQuoteCard 黑金主题 UI 调整
颜色配置:
- 背景:纯黑 #000000
- 边框/标签:金色 #C9A961
- 主要文字:亮金 #F4D03F
- 涨:红色 #F44336(红涨绿跌)
- 跌:绿色 #4CAF50

字体大小:
- 股票价格:48px bold
- 股票名称/代码:24px bold
- 涨跌幅 Badge:20px bold
- 关键指标数值:16px bold
- 标签文字:14px

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:54:19 +08:00
zdl
4274341ed5 feat: 动态跟踪添加新闻动态二级 Tab
- 添加 Tabs 结构支持二级 Tab 扩展
- Tab1: 新闻动态(复用 NewsEventsTab 组件)
- 实现 loadNewsEvents 数据加载逻辑
- 支持搜索和分页功能
- 自动获取股票名称用于新闻搜索

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:46:37 +08:00
zdl
40f6eaced6 refactor: 移除暗色模式相关代码,使用固定浅色主题
- DeepAnalysisTab: 移除 useColorModeValue,使用固定颜色值
- NewsEventsTab: 移除 useColorModeValue,简化 hover 颜色
- FinancialPanorama: 移除 useColorMode/useColorModeValue
- MarketDataView: 移除 dark 主题配置,简化颜色逻辑
- StockQuoteCard: 移除 useColorModeValue,使用固定颜色

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:46:30 +08:00
zdl
2dd7dd755a refactor: Company 页面一级 Tab 重构为 6 个
- 新增深度分析 Tab(从 CompanyOverview 提取为独立组件)
- 新增动态跟踪 Tab(占位组件,后续添加内容)
- Tab 顺序:公司概览 | 深度分析 | 股票行情 | 财务全景 | 盈利预测 | 动态跟踪
- 简化 CompanyOverview:移除内部 Tabs,只保留头部卡片 + 基本信息
- DeepAnalysis 组件独立管理深度分析数据加载(3个接口)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:52:23 +08:00
zdl
04ce16df56 perf: CompanyOverview Tab 懒加载优化
- 拆分 loadData 为 loadBasicInfoData 和 loadDeepAnalysisData
- 首次加载仅请求 9 个基本信息接口(原 12 个)
- 深度分析 3 个接口切换 Tab 时按需加载
- 新闻动态 1 个接口切换 Tab 时按需加载
- 调整 Tab 顺序:基本信息 → 深度分析 → 新闻动态
- stockCode 变更时重置 Tab 状态和数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:37:11 +08:00
zdl
d7759b1da3 feat: 添加股票行情卡片 2025-12-09 17:26:58 +08:00
zdl
701f96855e feat: 添加mock数据 2025-12-09 17:24:54 +08:00
zdl
cd1a5b743f feat: 添加mock 2025-12-09 17:12:13 +08:00
zdl
18c83237e2 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>
2025-12-09 17:11:42 +08:00
zdl
c1e10e6205 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_vf_h5_ui:
  feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步
  feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新
  fix: 修复投资日历切换月份时自动打开事件弹窗的问题
  fix: 修复 CompanyOverview 中 Hooks 顺序错误
2025-12-09 16:36:46 +08:00
zdl
4954c58525 refactor: Company 目录结构重组 - Tab 内容组件文件夹化
- 将 4 个 Tab 内容组件移动到 components/ 目录下
  - CompanyOverview.js → components/CompanyOverview/index.js
  - MarketDataView.js → components/MarketDataView/index.js
  - FinancialPanorama.js → components/FinancialPanorama/index.js
  - ForecastReport.js → components/ForecastReport/index.js
- 更新 CompanyTabs/index.js 导入路径
- 更新 routes/lazy-components.js 路由路径
- 修复组件内相对路径导入,改用 @utils/@services 别名

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 15:31:58 +08:00
zdl
91bd581a5e feat: 添加 useCompanyStock 股票代码管理 2025-12-09 15:18:06 +08:00
zdl
258708fca0 fix: bug修复 2025-12-09 15:16:02 +08:00
zdl
90391729bb feat: 处理自选股乐观更新 2025-12-09 15:15:20 +08:00
zdl
2148d319ad feat: 添加mock 数据 2025-12-09 15:08:15 +08:00
zdl
c61d58b0e3 feat: 添加Company 页面 Tab 切换组件 2025-12-09 15:01:16 +08:00
zdl
ed1c7b9fa9 feat: 添加Company 页面头部组件 CompanyHeader
index.js            # 组合导出
SearchBar.js        # 股票搜索栏
WatchlistButton.js  # 自选股按钮
2025-12-09 14:59:24 +08:00
27 changed files with 6018 additions and 3342 deletions

View File

@@ -0,0 +1,84 @@
/**
* FavoriteButton - 通用关注/收藏按钮组件(图标按钮)
*/
import React from 'react';
import { IconButton, Tooltip, Spinner } from '@chakra-ui/react';
import { Star } from 'lucide-react';
export interface FavoriteButtonProps {
/** 是否已关注 */
isFavorite: boolean;
/** 加载状态 */
isLoading?: boolean;
/** 点击回调 */
onClick: () => void;
/** 按钮大小 */
size?: 'sm' | 'md' | 'lg';
/** 颜色主题 */
colorScheme?: 'gold' | 'default';
/** 是否显示 tooltip */
showTooltip?: boolean;
}
// 颜色配置
const COLORS = {
gold: {
active: '#F4D03F', // 已关注 - 亮金色
inactive: '#C9A961', // 未关注 - 暗金色
hoverBg: 'whiteAlpha.100',
},
default: {
active: 'yellow.400',
inactive: 'gray.400',
hoverBg: 'gray.100',
},
};
const FavoriteButton: React.FC<FavoriteButtonProps> = ({
isFavorite,
isLoading = false,
onClick,
size = 'sm',
colorScheme = 'gold',
showTooltip = true,
}) => {
const colors = COLORS[colorScheme];
const currentColor = isFavorite ? colors.active : colors.inactive;
const label = isFavorite ? '取消关注' : '加入自选';
const iconButton = (
<IconButton
aria-label={label}
icon={
isLoading ? (
<Spinner size="sm" color={currentColor} />
) : (
<Star
size={size === 'sm' ? 18 : size === 'md' ? 20 : 24}
fill={isFavorite ? currentColor : 'none'}
stroke={currentColor}
/>
)
}
variant="ghost"
color={currentColor}
size={size}
onClick={onClick}
isDisabled={isLoading}
_hover={{ bg: colors.hoverBg }}
/>
);
if (showTooltip) {
return (
<Tooltip label={label} placement="top">
{iconButton}
</Tooltip>
);
}
return iconButton;
};
export default FavoriteButton;

View File

@@ -546,7 +546,9 @@ const InvestmentCalendar = () => {
{concepts && concepts.length > 0 ? (
concepts.slice(0, 3).map((concept, index) => (
<Tag key={index} icon={<TagsOutlined />}>
{Array.isArray(concept) ? concept[0] : concept}
{typeof concept === 'string'
? concept
: (concept?.concept || concept?.name || '未知')}
</Tag>
))
) : (
@@ -940,7 +942,7 @@ const InvestmentCalendar = () => {
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record[0]}
rowKey={(record) => record.code}
size="middle"
pagination={false}
/>

File diff suppressed because it is too large Load Diff

View File

@@ -43,12 +43,10 @@ export const companyHandlers = [
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline
return HttpResponse.json({
success: true,
data: {
timeline: data.keyFactorsTimeline,
total: data.keyFactorsTimeline.length
}
data: data.keyFactorsTimeline
});
}),

View File

@@ -35,9 +35,9 @@ export const lazyComponents = {
// 公司相关模块
CompanyIndex: React.lazy(() => import('@views/Company')),
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('@views/AgentChat')),

View File

@@ -340,6 +340,26 @@ const stockSlice = createSlice({
delete state.historicalEventsCache[eventId];
delete state.chainAnalysisCache[eventId];
delete state.expectationScores[eventId];
},
/**
* 乐观更新:添加自选股(同步)
*/
optimisticAddWatchlist: (state, action) => {
const { stockCode, stockName } = action.payload;
// 避免重复添加
const exists = state.watchlist.some(item => item.stock_code === stockCode);
if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' });
}
},
/**
* 乐观更新:移除自选股(同步)
*/
optimisticRemoveWatchlist: (state, action) => {
const { stockCode } = action.payload;
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
},
extraReducers: (builder) => {
@@ -481,7 +501,9 @@ export const {
updateQuote,
updateQuotes,
clearQuotes,
clearEventCache
clearEventCache,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} = stockSlice.actions;
export default stockSlice.reducer;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
// src/views/Company/components/CompanyHeader/SearchBar.js
// 股票搜索栏组件 - 金色主题
import React from 'react';
import {
HStack,
Input,
Button,
InputGroup,
InputLeftElement,
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
/**
* 股票搜索栏组件
*
* @param {Object} props
* @param {string} props.inputCode - 输入框当前值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索按钮点击回调
* @param {Function} props.onKeyPress - 键盘事件回调
*/
const SearchBar = ({
inputCode,
onInputChange,
onSearch,
onKeyPress,
}) => {
return (
<HStack spacing={3}>
<InputGroup size="lg" maxW="300px">
<InputLeftElement pointerEvents="none">
<SearchIcon color="#C9A961" />
</InputLeftElement>
<Input
placeholder="输入股票代码"
value={inputCode}
onChange={(e) => onInputChange(e.target.value)}
onKeyPress={onKeyPress}
borderRadius="md"
color="white"
borderColor="#C9A961"
_placeholder={{ color: '#C9A961' }}
_focus={{
borderColor: '#F4D03F',
boxShadow: '0 0 0 1px #F4D03F',
}}
_hover={{
borderColor: '#F4D03F',
}}
/>
</InputGroup>
<Button
size="lg"
onClick={onSearch}
leftIcon={<SearchIcon />}
bg="#1A202C"
color="#C9A961"
borderWidth="1px"
borderColor="#C9A961"
_hover={{ bg: '#1a1a1a', borderColor: '#F4D03F', color: '#F4D03F' }}
>
查询
</Button>
</HStack>
);
};
export default SearchBar;

View File

@@ -0,0 +1,62 @@
// src/views/Company/components/CompanyHeader/index.js
// 公司详情页面头部区域组件
import React from 'react';
import {
Card,
CardBody,
HStack,
VStack,
Heading,
Text,
} from '@chakra-ui/react';
import SearchBar from './SearchBar';
/**
* 公司详情页面头部区域组件
*
* 包含:
* - 页面标题和描述(金色主题)
* - 股票搜索栏
*
* @param {Object} props
* @param {string} props.inputCode - 搜索输入框值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索回调
* @param {Function} props.onKeyPress - 键盘事件回调
* @param {string} props.bgColor - 背景颜色
*/
const CompanyHeader = ({
inputCode,
onInputChange,
onSearch,
onKeyPress,
bgColor,
}) => {
return (
<Card bg={bgColor} shadow="md">
<CardBody>
<HStack justify="space-between" align="center">
{/* 标题区域 - 金色主题 */}
<VStack align="start" spacing={1}>
<Heading size="lg" color="#F4D03F">个股详情</Heading>
<Text color="#C9A961" fontSize="sm">
查看股票实时行情财务数据和盈利预测
</Text>
</VStack>
{/* 搜索栏 */}
<SearchBar
inputCode={inputCode}
onInputChange={onInputChange}
onSearch={onSearch}
onKeyPress={onKeyPress}
/>
</HStack>
</CardBody>
</Card>
);
};
export default CompanyHeader;

View File

@@ -0,0 +1,942 @@
// 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,
loading = false,
}) => {
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,537 @@
// 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,
} 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 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: "gray.50",
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;

View File

@@ -0,0 +1,329 @@
// src/views/Company/components/CompanyOverview/index.js
// 公司概览 - 头部卡片 + 基本信息
import React, { useState, useEffect } from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
Heading,
SimpleGrid,
Divider,
Spinner,
Center,
Icon,
Grid,
GridItem,
Stat,
StatLabel,
StatNumber,
Circle,
Link,
} from "@chakra-ui/react";
import {
FaBuilding,
FaMapMarkerAlt,
FaUserShield,
FaBriefcase,
FaCalendarAlt,
FaGlobe,
FaEnvelope,
FaPhone,
FaCrown,
} from "react-icons/fa";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
// 子组件
import BasicInfoTab from "./BasicInfoTab";
// API配置
const API_BASE_URL = getApiBase();
// 格式化工具
const formatUtils = {
formatRegisteredCapital: (value) => {
if (!value && value !== 0) return "-";
const absValue = Math.abs(value);
if (absValue >= 100000) {
return (value / 10000).toFixed(2) + "亿元";
}
return value.toFixed(2) + "万元";
},
formatDate: (dateString) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("zh-CN");
},
};
/**
* 公司概览组件
*
* 功能:
* - 显示公司头部信息卡片
* - 显示基本信息(股权结构、管理层、公告等)
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const CompanyOverview = ({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || "000001");
const [loading, setLoading] = useState(false);
const [dataLoaded, setDataLoaded] = useState(false);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
setDataLoaded(false);
}
}, [propStockCode, stockCode]);
// 基本信息数据
const [basicInfo, setBasicInfo] = useState(null);
const [actualControl, setActualControl] = useState([]);
const [concentration, setConcentration] = useState([]);
const [management, setManagement] = useState([]);
const [topCirculationShareholders, setTopCirculationShareholders] = useState([]);
const [topShareholders, setTopShareholders] = useState([]);
const [branches, setBranches] = useState([]);
const [announcements, setAnnouncements] = useState([]);
const [disclosureSchedule, setDisclosureSchedule] = useState([]);
const [_error, setError] = useState(null);
// 加载基本信息数据9个接口
const loadBasicInfoData = async () => {
if (dataLoaded) return;
setLoading(true);
setError(null);
try {
const requests = [
fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`).then((r) =>
r.json()
),
fetch(
`${API_BASE_URL}/api/stock/${stockCode}/actual-control`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/stock/${stockCode}/concentration`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`
).then((r) => r.json()),
fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`).then((r) =>
r.json()
),
fetch(
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
).then((r) => r.json()),
];
const [
basicRes,
actualRes,
concentrationRes,
managementRes,
circulationRes,
shareholdersRes,
branchesRes,
announcementsRes,
disclosureRes,
] = await Promise.all(requests);
if (basicRes.success) setBasicInfo(basicRes.data);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (managementRes.success) setManagement(managementRes.data);
if (circulationRes.success)
setTopCirculationShareholders(circulationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (branchesRes.success) setBranches(branchesRes.data);
if (announcementsRes.success) setAnnouncements(announcementsRes.data);
if (disclosureRes.success) setDisclosureSchedule(disclosureRes.data);
setDataLoaded(true);
} catch (err) {
setError(err.message);
logger.error("CompanyOverview", "loadBasicInfoData", err, { stockCode });
} finally {
setLoading(false);
}
};
// 首次加载
useEffect(() => {
if (stockCode) {
loadBasicInfoData();
}
}, [stockCode]);
if (loading && !basicInfo) {
return (
<Center h="300px">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text>正在加载公司概览数据...</Text>
</VStack>
</Center>
);
}
return (
<VStack spacing={6} align="stretch">
{/* 公司头部信息卡片 */}
{basicInfo && (
<Card
bg="white"
shadow="lg"
borderTop="4px solid"
borderTopColor="blue.500"
>
<CardBody>
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
<GridItem colSpan={{ base: 12, lg: 8 }}>
<VStack align="start" spacing={4}>
<HStack spacing={4}>
<Circle size="60px" bg="blue.500">
<Icon as={FaBuilding} color="white" boxSize={8} />
</Circle>
<VStack align="start" spacing={1}>
<HStack>
<Heading size="lg" color="blue.600">
{basicInfo.ORGNAME || basicInfo.SECNAME}
</Heading>
<Badge colorScheme="blue" fontSize="md" px={2} py={1}>
{basicInfo.SECCODE}
</Badge>
</HStack>
<HStack spacing={2}>
<Badge colorScheme="purple" fontSize="xs">
{basicInfo.sw_industry_l1}
</Badge>
<Badge colorScheme="orange" fontSize="xs">
{basicInfo.sw_industry_l2}
</Badge>
{basicInfo.sw_industry_l3 && (
<Badge colorScheme="green" fontSize="xs">
{basicInfo.sw_industry_l3}
</Badge>
)}
</HStack>
</VStack>
</HStack>
<Divider />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} w="full">
<HStack>
<Icon as={FaUserShield} color="gray.500" boxSize={4} />
<Text fontSize="sm">
<Text as="span" color="gray.500">法定代表人</Text>
<Text as="span" fontWeight="bold">{basicInfo.legal_representative}</Text>
</Text>
</HStack>
<HStack>
<Icon as={FaCrown} color="gray.500" boxSize={4} />
<Text fontSize="sm">
<Text as="span" color="gray.500">董事长</Text>
<Text as="span" fontWeight="bold">{basicInfo.chairman}</Text>
</Text>
</HStack>
<HStack>
<Icon as={FaBriefcase} color="gray.500" boxSize={4} />
<Text fontSize="sm">
<Text as="span" color="gray.500">总经理</Text>
<Text as="span" fontWeight="bold">{basicInfo.general_manager}</Text>
</Text>
</HStack>
<HStack>
<Icon as={FaCalendarAlt} color="gray.500" boxSize={4} />
<Text fontSize="sm">
<Text as="span" color="gray.500">成立日期</Text>
<Text as="span" fontWeight="bold">{formatUtils.formatDate(basicInfo.establish_date)}</Text>
</Text>
</HStack>
</SimpleGrid>
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{basicInfo.company_intro}
</Text>
</VStack>
</GridItem>
<GridItem colSpan={{ base: 12, lg: 4 }}>
<VStack spacing={3} align="stretch">
<Stat>
<StatLabel>注册资本</StatLabel>
<StatNumber fontSize="2xl" color="blue.500">
{formatUtils.formatRegisteredCapital(basicInfo.reg_capital)}
</StatNumber>
</Stat>
<Divider />
<VStack align="stretch" spacing={1}>
<HStack fontSize="sm">
<Icon as={FaMapMarkerAlt} color="gray.500" boxSize={3} />
<Text noOfLines={1}>{basicInfo.province} {basicInfo.city}</Text>
</HStack>
<HStack fontSize="sm">
<Icon as={FaGlobe} color="gray.500" boxSize={3} />
<Link href={basicInfo.website} isExternal color="blue.500" noOfLines={1}>
{basicInfo.website} <ExternalLinkIcon mx="2px" />
</Link>
</HStack>
<HStack fontSize="sm">
<Icon as={FaEnvelope} color="gray.500" boxSize={3} />
<Text noOfLines={1}>{basicInfo.email}</Text>
</HStack>
<HStack fontSize="sm">
<Icon as={FaPhone} color="gray.500" boxSize={3} />
<Text>{basicInfo.tel}</Text>
</HStack>
</VStack>
</VStack>
</GridItem>
</Grid>
</CardBody>
</Card>
)}
{/* 基本信息内容 */}
<BasicInfoTab
basicInfo={basicInfo}
actualControl={actualControl}
concentration={concentration}
topShareholders={topShareholders}
topCirculationShareholders={topCirculationShareholders}
management={management}
announcements={announcements}
branches={branches}
disclosureSchedule={disclosureSchedule}
cardBg="white"
loading={loading}
/>
</VStack>
);
};
export default CompanyOverview;

View File

@@ -0,0 +1,55 @@
// src/views/Company/components/CompanyTabs/TabNavigation.js
// Tab 导航组件 - 动态渲染 Tab 按钮(黑金主题)
import React from 'react';
import {
TabList,
Tab,
HStack,
Icon,
Text,
} from '@chakra-ui/react';
import { COMPANY_TABS } from '../../constants';
// 黑金主题颜色配置
const THEME_COLORS = {
bg: '#1A202C', // 背景纯黑
selectedBg: '#C9A961', // 选中项金色背景
selectedText: '#FFFFFF', // 选中项白色文字
unselectedText: '#999999', // 未选中项深灰色
};
/**
* Tab 导航组件(黑金主题)
*/
const TabNavigation = () => {
return (
<TabList py={4} bg={THEME_COLORS.bg} borderTopLeftRadius="16px" borderTopRightRadius="16px">
{COMPANY_TABS.map((tab, index) => (
<Tab
key={tab.key}
color={THEME_COLORS.unselectedText}
borderRadius="full"
px={4}
py={2}
_selected={{
bg: THEME_COLORS.selectedBg,
color: THEME_COLORS.selectedText,
}}
_hover={{
color: THEME_COLORS.selectedText,
}}
mr={index < COMPANY_TABS.length - 1 ? 2 : 0}
>
<HStack spacing={2}>
<Icon as={tab.icon} boxSize="18px" />
<Text fontSize="15px">{tab.name}</Text>
</HStack>
</Tab>
))}
</TabList>
);
};
export default TabNavigation;

View File

@@ -0,0 +1,98 @@
// src/views/Company/components/CompanyTabs/index.js
// Tab 容器组件 - 管理 Tab 切换和内容渲染
import React, { useState } from 'react';
import {
Card,
CardBody,
Tabs,
TabPanels,
TabPanel,
Divider,
} from '@chakra-ui/react';
import TabNavigation from './TabNavigation';
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
// 子组件导入Tab 内容组件)
import CompanyOverview from '../CompanyOverview';
import DeepAnalysis from '../DeepAnalysis';
import MarketDataView from '../MarketDataView';
import FinancialPanorama from '../FinancialPanorama';
import ForecastReport from '../ForecastReport';
import DynamicTracking from '../DynamicTracking';
/**
* Tab 组件映射
* key 与 COMPANY_TABS 中的 key 对应
*/
const TAB_COMPONENTS = {
overview: CompanyOverview,
analysis: DeepAnalysis,
market: MarketDataView,
financial: FinancialPanorama,
forecast: ForecastReport,
tracking: DynamicTracking,
};
/**
* Tab 容器组件
*
* 功能:
* - 管理 Tab 切换状态
* - 动态渲染 Tab 导航和内容
* - 触发 Tab 变更追踪
*
* @param {Object} props
* @param {string} props.stockCode - 当前股票代码
* @param {Function} props.onTabChange - Tab 变更回调 (index, tabName, prevIndex) => void
*/
const CompanyTabs = ({ stockCode, onTabChange }) => {
const [currentIndex, setCurrentIndex] = useState(0);
/**
* 处理 Tab 切换
*/
const handleTabChange = (index) => {
const tabName = getTabNameByIndex(index);
// 触发追踪回调
onTabChange?.(index, tabName, currentIndex);
// 更新状态
setCurrentIndex(index);
};
return (
<Card shadow="lg" bg='#1A202C'>
<CardBody p={0}>
<Tabs
variant="soft-rounded"
colorScheme="blue"
size="lg"
index={currentIndex}
onChange={handleTabChange}
>
{/* Tab 导航(黑金主题) */}
<TabNavigation />
<Divider />
{/* Tab 内容面板 */}
<TabPanels>
{COMPANY_TABS.map((tab) => {
const Component = TAB_COMPONENTS[tab.key];
return (
<TabPanel key={tab.key}>
<Component stockCode={stockCode} />
</TabPanel>
);
})}
</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
export default CompanyTabs;

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/DeepAnalysis/index.js
// 深度分析 - 独立一级 Tab 组件
import React, { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
// 复用原有的展示组件
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
const API_BASE_URL = getApiBase();
/**
* 深度分析组件
*
* 功能:
* - 加载深度分析数据3个接口
* - 管理展开状态
* - 渲染 DeepAnalysisTab 展示组件
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DeepAnalysis = ({ stockCode }) => {
// 数据状态
const [comprehensiveData, setComprehensiveData] = useState(null);
const [valueChainData, setValueChainData] = useState(null);
const [keyFactorsData, setKeyFactorsData] = useState(null);
const [loading, setLoading] = useState(false);
// 业务板块展开状态
const [expandedSegments, setExpandedSegments] = useState({});
// 切换业务板块展开状态
const toggleSegmentExpansion = (segmentIndex) => {
setExpandedSegments((prev) => ({
...prev,
[segmentIndex]: !prev[segmentIndex],
}));
};
// 加载深度分析数据3个接口
const loadDeepAnalysisData = async () => {
if (!stockCode) return;
setLoading(true);
try {
const requests = [
fetch(
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
).then((r) => r.json()),
];
const [comprehensiveRes, valueChainRes, keyFactorsRes] =
await Promise.all(requests);
if (comprehensiveRes.success) setComprehensiveData(comprehensiveRes.data);
if (valueChainRes.success) setValueChainData(valueChainRes.data);
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
} catch (err) {
logger.error("DeepAnalysis", "loadDeepAnalysisData", err, { stockCode });
} finally {
setLoading(false);
}
};
// stockCode 变更时重新加载数据
useEffect(() => {
if (stockCode) {
// 重置数据
setComprehensiveData(null);
setValueChainData(null);
setKeyFactorsData(null);
setExpandedSegments({});
// 加载新数据
loadDeepAnalysisData();
}
}, [stockCode]);
return (
<DeepAnalysisTab
comprehensiveData={comprehensiveData}
valueChainData={valueChainData}
keyFactorsData={keyFactorsData}
loading={loading}
cardBg="white"
expandedSegments={expandedSegments}
onToggleSegment={toggleSegmentExpansion}
/>
);
};
export default DeepAnalysis;

View File

@@ -0,0 +1,184 @@
// src/views/Company/components/DynamicTracking/index.js
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab
import React, { useState, useEffect, useCallback } from "react";
import {
Box,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
} from "@chakra-ui/react";
import { FaNewspaper } from "react-icons/fa";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import NewsEventsTab from "../CompanyOverview/NewsEventsTab";
// API配置
const API_BASE_URL = getApiBase();
// 二级 Tab 配置
const TRACKING_TABS = [
{ key: "news", name: "新闻动态", icon: FaNewspaper },
// 后续可扩展更多二级 Tab
];
/**
* 动态跟踪组件
*
* 功能:
* - 二级 Tab 结构
* - Tab1: 新闻动态(复用 NewsEventsTab
* - 预留后续扩展
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DynamicTracking = ({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || "000001");
const [activeTab, setActiveTab] = useState(0);
// 新闻动态状态
const [newsEvents, setNewsEvents] = useState([]);
const [newsLoading, setNewsLoading] = useState(false);
const [newsPagination, setNewsPagination] = useState({
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
});
const [searchQuery, setSearchQuery] = useState("");
const [stockName, setStockName] = useState("");
const [dataLoaded, setDataLoaded] = useState(false);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
setDataLoaded(false);
setNewsEvents([]);
setStockName("");
setSearchQuery("");
}
}, [propStockCode, stockCode]);
// 获取股票名称(用于搜索)
const fetchStockName = useCallback(async () => {
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
);
const result = await response.json();
if (result.success && result.data) {
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
setStockName(name);
return name;
}
return stockCode;
} catch (err) {
logger.error("DynamicTracking", "fetchStockName", err, { stockCode });
return stockCode;
}
}, [stockCode]);
// 加载新闻事件数据
const loadNewsEvents = useCallback(
async (query, page = 1) => {
setNewsLoading(true);
try {
const searchTerm = query || stockName || stockCode;
const response = await fetch(
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
);
const result = await response.json();
if (result.success) {
setNewsEvents(result.data || []);
setNewsPagination({
page: result.pagination?.page || page,
per_page: result.pagination?.per_page || 10,
total: result.pagination?.total || 0,
pages: result.pagination?.pages || 0,
has_next: result.pagination?.has_next || false,
has_prev: result.pagination?.has_prev || false,
});
}
} catch (err) {
logger.error("DynamicTracking", "loadNewsEvents", err, { stockCode });
setNewsEvents([]);
} finally {
setNewsLoading(false);
}
},
[stockCode, stockName]
);
// 首次加载
useEffect(() => {
const initLoad = async () => {
if (stockCode && !dataLoaded) {
const name = await fetchStockName();
await loadNewsEvents(name, 1);
setDataLoaded(true);
}
};
initLoad();
}, [stockCode, dataLoaded, fetchStockName, loadNewsEvents]);
// 搜索处理
const handleSearchChange = (value) => {
setSearchQuery(value);
};
const handleSearch = () => {
loadNewsEvents(searchQuery || stockName, 1);
};
// 分页处理
const handlePageChange = (page) => {
loadNewsEvents(searchQuery || stockName, page);
};
return (
<Box>
<Tabs
variant="enclosed"
colorScheme="blue"
index={activeTab}
onChange={setActiveTab}
>
<TabList>
{TRACKING_TABS.map((tab) => (
<Tab key={tab.key} fontWeight="medium">
{tab.name}
</Tab>
))}
</TabList>
<TabPanels>
{/* 新闻动态 Tab */}
<TabPanel p={4}>
<NewsEventsTab
newsEvents={newsEvents}
newsLoading={newsLoading}
newsPagination={newsPagination}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
onPageChange={handlePageChange}
cardBg="white"
/>
</TabPanel>
{/* 后续可扩展更多 Tab Panel */}
</TabPanels>
</Tabs>
</Box>
);
};
export default DynamicTracking;

View File

@@ -1,6 +1,6 @@
// src/views/Company/FinancialPanorama.jsx
import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger';
import { logger } from '@utils/logger';
import {
Box,
Container,
@@ -35,7 +35,6 @@ import {
VStack,
HStack,
Divider,
useColorModeValue,
Select,
Button,
Tooltip,
@@ -60,7 +59,6 @@ import {
ButtonGroup,
Stack,
Collapse,
useColorMode,
} from '@chakra-ui/react';
import {
ChevronDownIcon,
@@ -75,7 +73,7 @@ import {
ArrowDownIcon,
} from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { financialService, formatUtils, chartUtils } from '../../services/financialService';
import { financialService, formatUtils, chartUtils } from '@services/financialService';
const FinancialPanorama = ({ stockCode: propStockCode }) => {
// 状态管理
@@ -84,7 +82,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const [error, setError] = useState(null);
const [selectedPeriods, setSelectedPeriods] = useState(8);
const [activeTab, setActiveTab] = useState(0);
// 财务数据状态
const [stockInfo, setStockInfo] = useState(null);
const [balanceSheet, setBalanceSheet] = useState([]);
@@ -101,14 +99,13 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const [modalContent, setModalContent] = useState(null);
const [expandedRows, setExpandedRows] = useState({});
const toast = useToast();
const { colorMode } = useColorMode();
// 颜色配置(中国市场:红涨绿跌)
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const positiveColor = useColorModeValue('red.500', 'red.400'); // 红涨
const negativeColor = useColorModeValue('green.500', 'green.400'); // 绿跌
const bgColor = 'white';
const borderColor = 'gray.200';
const hoverBg = 'gray.50';
const positiveColor = 'red.500'; // 红涨
const negativeColor = 'green.500'; // 绿跌
// 加载所有财务数据
const loadFinancialData = async () => {
@@ -492,7 +489,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
<React.Fragment key={section.key}>
{section.title !== '资产总计' && section.title !== '负债合计' && (
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
@@ -515,7 +512,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, balanceSheet, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') : 'transparent'}
bg={metric.isTotal ? 'blue.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
@@ -733,7 +730,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const renderSection = (section) => (
<React.Fragment key={section.key}>
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
@@ -755,8 +752,8 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, incomeStatement, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') :
metric.isSubtotal ? useColorModeValue('orange.50', 'orange.900') : 'transparent'}
bg={metric.isTotal ? 'blue.50' :
metric.isSubtotal ? 'orange.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
@@ -1268,7 +1265,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
{ label: '资产负债率', value: financialMetrics[0].solvency?.asset_liability_ratio, format: 'percent' },
{ label: '研发费用率', value: financialMetrics[0].expense_ratios?.rd_expense_ratio, format: 'percent' },
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg={useColorModeValue('gray.50', 'gray.700')}>
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
<Text fontSize="xs" color="gray.500">{item.label}</Text>
<Text fontSize="lg" fontWeight="bold">
{item.format === 'percent' ?

View File

@@ -4,7 +4,7 @@ import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack }
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { stockService } from '../../services/eventService';
import { stockService } from '@services/eventService';
const ForecastReport = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');

View File

@@ -1,7 +1,7 @@
// src/views/Market/MarketDataPro.jsx
import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig';
import {
Box,
Container,
@@ -36,7 +36,6 @@ import {
VStack,
HStack,
Divider,
useColorModeValue,
Select,
Button,
Tooltip,
@@ -60,7 +59,6 @@ import {
GridItem,
ButtonGroup,
Stack,
useColorMode,
Icon,
InputGroup,
InputLeftElement,
@@ -121,25 +119,6 @@ const themes = {
border: '#CBD5E0',
chartBg: '#FFFFFF',
},
dark: {
// 夜间模式 - 黑+金
primary: '#FFD700',
primaryDark: '#FFA500',
secondary: '#1A1A1A',
secondaryDark: '#000000',
success: '#FF4444', // 涨 - 红色
danger: '#00C851', // 跌 - 绿色
warning: '#FFA500',
info: '#00BFFF',
bgMain: '#0A0A0A',
bgCard: '#141414',
bgDark: '#000000',
textPrimary: '#FFFFFF',
textSecondary: '#FFD700',
textMuted: '#999999',
border: '#333333',
chartBg: '#141414',
}
};
// API服务
@@ -236,9 +215,9 @@ const ThemedCard = ({ children, theme, ...props }) => {
};
// Markdown渲染组件
const MarkdownRenderer = ({ children, theme, colorMode }) => {
const MarkdownRenderer = ({ children, theme }) => {
return (
<Box
<Box
color={theme.textPrimary}
sx={{
'& h1, & h2, & h3, & h4, & h5, & h6': {
@@ -269,7 +248,7 @@ const MarkdownRenderer = ({ children, theme, colorMode }) => {
fontStyle: 'italic'
},
'& code': {
backgroundColor: colorMode === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.1)',
backgroundColor: 'rgba(0,0,0,0.05)',
padding: '2px 4px',
borderRadius: '4px',
fontSize: '0.9em'
@@ -290,20 +269,19 @@ const MarkdownRenderer = ({ children, theme, colorMode }) => {
// 主组件
const MarketDataView = ({ stockCode: propStockCode }) => {
const { colorMode } = useColorMode();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const [modalContent, setModalContent] = useState(null);
// 获取当前主题
const theme = colorMode === 'light' ? themes.light : themes.dark;
const theme = themes.light;
// 状态管理
const [stockCode, setStockCode] = useState(propStockCode || '600000');
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState(0);
const [selectedPeriod, setSelectedPeriod] = useState(60);
// 数据状态
const [summary, setSummary] = useState(null);
const [tradeData, setTradeData] = useState([]);
@@ -464,7 +442,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
opacity: 0.8
}
},
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
@@ -682,7 +660,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.85)',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
@@ -889,7 +867,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
},
tooltip: {
trigger: 'axis',
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
@@ -1022,7 +1000,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
},
tooltip: {
trigger: 'axis',
backgroundColor: colorMode === 'light' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.8)',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
@@ -1130,7 +1108,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<Heading size="xl" color={theme.textSecondary}>
{summary.stock_name}
</Heading>
<Badge colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="lg">
<Badge colorScheme={'blue'} fontSize="lg">
{summary.stock_code}
</Badge>
</HStack>
@@ -1236,7 +1214,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
) : (
<Tabs
variant="soft-rounded"
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
colorScheme={'blue'}
index={activeTab}
onChange={setActiveTab}
>
@@ -1249,31 +1227,31 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
>
<HStack justify="space-between" align="center" spacing={4}>
<TabList overflowX="auto" border="none" flex="1">
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
<Tab color={theme.textMuted} _selected={{ color: 'white', bg: theme.primary }} fontSize="sm" px={3}>
<HStack spacing={1}>
<Icon as={ChevronUpIcon} boxSize={4} />
<Text>交易数据</Text>
</HStack>
</Tab>
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
<Tab color={theme.textMuted} _selected={{ color: 'white', bg: theme.primary }} fontSize="sm" px={3}>
<HStack spacing={1}>
<Icon as={UnlockIcon} boxSize={4} />
<Text>融资融券</Text>
</HStack>
</Tab>
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
<Tab color={theme.textMuted} _selected={{ color: 'white', bg: theme.primary }} fontSize="sm" px={3}>
<HStack spacing={1}>
<Icon as={ArrowUpIcon} boxSize={4} />
<Text>大宗交易</Text>
</HStack>
</Tab>
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
<Tab color={theme.textMuted} _selected={{ color: 'white', bg: theme.primary }} fontSize="sm" px={3}>
<HStack spacing={1}>
<Icon as={StarIcon} boxSize={4} />
<Text>龙虎榜</Text>
</HStack>
</Tab>
<Tab color={theme.textMuted} _selected={{ color: colorMode === 'light' ? 'white' : theme.secondary, bg: theme.primary }} fontSize="sm" px={3}>
<Tab color={theme.textMuted} _selected={{ color: 'white', bg: theme.primary }} fontSize="sm" px={3}>
<HStack spacing={1}>
<Icon as={LockIcon} boxSize={4} />
<Text>股权质押</Text>
@@ -1301,7 +1279,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<Button
leftIcon={<RepeatIcon />}
variant="outline"
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
colorScheme={'blue'}
onClick={loadMarketData}
isLoading={loading}
size="sm"
@@ -1323,7 +1301,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<ReactECharts
option={getKLineOption()}
style={{ height: '100%', width: '100%' }}
theme={colorMode === 'light' ? 'light' : 'dark'}
theme={'light'}
onEvents={{
'click': (params) => {
if (params.seriesName === '涨幅分析' && params.data) {
@@ -1343,7 +1321,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
</Box>
{analysis.main_business && (
<Box p={4} bg={colorMode === 'light' ? 'gray.50' : 'gray.900'} borderRadius="md">
<Box p={4} bg={'gray.50'} borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>主营业务</Heading>
<Text color={theme.textPrimary}>{analysis.main_business}</Text>
</Box>
@@ -1351,18 +1329,18 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
{analysis.rise_reason_detail && (
<Box p={4} bg={colorMode === 'light' ? 'purple.50' : 'purple.900'} borderRadius="md">
<Box p={4} bg={'purple.50'} borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>详细分析</Heading>
<MarkdownRenderer theme={theme} colorMode={colorMode}>
<MarkdownRenderer theme={theme}>
{analysis.rise_reason_detail}
</MarkdownRenderer>
</Box>
)}
{analysis.announcements && analysis.announcements !== '[]' && (
<Box p={4} bg={colorMode === 'light' ? 'orange.50' : 'orange.900'} borderRadius="md">
<Box p={4} bg={'orange.50'} borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>相关公告</Heading>
<MarkdownRenderer theme={theme} colorMode={colorMode}>
<MarkdownRenderer theme={theme}>
{analysis.announcements}
</MarkdownRenderer>
</Box>
@@ -1370,7 +1348,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
{/* 研报引用展示 */}
{analysis.verification_reports && analysis.verification_reports.length > 0 && (
<Box p={4} bg={colorMode === 'light' ? 'blue.50' : 'blue.900'} borderRadius="md">
<Box p={4} bg={'blue.50'} borderRadius="md">
<Heading size="sm" mb={3} color={theme.primary}>
<HStack spacing={2}>
<Icon as={ExternalLinkIcon} />
@@ -1382,7 +1360,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<Box
key={reportIdx}
p={3}
bg={colorMode === 'light' ? 'white' : 'gray.800'}
bg={'white'}
borderRadius="md"
border="1px solid"
borderColor={theme.border}
@@ -1428,7 +1406,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
{report.verification_item && (
<Box
p={2}
bg={colorMode === 'light' ? 'yellow.50' : 'yellow.900'}
bg={'yellow.50'}
borderRadius="sm"
mb={2}
>
@@ -1479,7 +1457,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
当日分钟频数据
</Heading>
{minuteData && minuteData.trade_date && (
<Badge colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="xs">
<Badge colorScheme={'blue'} fontSize="xs">
{minuteData.trade_date}
</Badge>
)}
@@ -1488,7 +1466,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
leftIcon={<RepeatIcon />}
size="sm"
variant="outline"
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
colorScheme={'blue'}
onClick={loadMinuteData}
isLoading={minuteLoading}
loadingText="获取中"
@@ -1520,7 +1498,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<ReactECharts
option={getMinuteKLineOption()}
style={{ height: '100%', width: '100%' }}
theme={colorMode === 'light' ? 'light' : 'dark'}
theme={'light'}
/>
</Box>
@@ -1592,7 +1570,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
{/* 成交量分析 */}
<Box
p={4}
bg={colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.05)'}
bg={theme.bgDark}
borderRadius="lg"
border="1px solid"
borderColor={theme.border}
@@ -1687,7 +1665,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
</Thead>
<Tbody>
{tradeData.slice(-10).reverse().map((item, idx) => (
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
<Tr key={idx} _hover={{ bg: theme.bgDark }}>
<Td color={theme.textPrimary}>{item.date}</Td>
<Td isNumeric color={theme.textPrimary}>{item.open}</Td>
<Td isNumeric color={theme.textPrimary}>{item.high}</Td>
@@ -1718,7 +1696,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<ReactECharts
option={getFundingOption()}
style={{ height: '100%', width: '100%' }}
theme={colorMode === 'light' ? 'light' : 'dark'}
theme={'light'}
/>
</Box>
)}
@@ -1735,7 +1713,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<CardBody>
<VStack spacing={3} align="stretch">
{fundingData.slice(-5).reverse().map((item, idx) => (
<Box key={idx} p={3} bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'} borderRadius="md">
<Box key={idx} p={3} bg={'rgba(255, 68, 68, 0.05)'} borderRadius="md">
<HStack justify="space-between">
<Text color={theme.textMuted}>{item.date}</Text>
<VStack align="end" spacing={0}>
@@ -1762,7 +1740,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<CardBody>
<VStack spacing={3} align="stretch">
{fundingData.slice(-5).reverse().map((item, idx) => (
<Box key={idx} p={3} bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'} borderRadius="md">
<Box key={idx} p={3} bg={'rgba(0, 200, 81, 0.05)'} borderRadius="md">
<HStack justify="space-between">
<Text color={theme.textMuted}>{item.date}</Text>
<VStack align="end" spacing={0}>
@@ -1798,7 +1776,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<Box
key={idx}
p={4}
bg={colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.05)'}
bg={theme.bgDark}
borderRadius="lg"
border="1px solid"
borderColor={theme.border}
@@ -1808,7 +1786,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
{dayStats.date}
</Text>
<HStack spacing={4}>
<Badge colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="md">
<Badge colorScheme={'blue'} fontSize="md">
交易笔数: {dayStats.count}
</Badge>
<Badge colorScheme="green" fontSize="md">
@@ -1838,7 +1816,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
</Thead>
<Tbody>
{dayStats.deals.map((deal, i) => (
<Tr key={i} _hover={{ bg: colorMode === 'light' ? 'rgba(43, 108, 176, 0.05)' : 'rgba(255, 215, 0, 0.1)' }}>
<Tr key={i} _hover={{ bg: 'rgba(43, 108, 176, 0.05)' }}>
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
<Tooltip label={deal.buyer_dept || '-'} placement="top">
<Text>{deal.buyer_dept || '-'}</Text>
@@ -1891,7 +1869,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<Box
key={idx}
p={4}
bg={colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.05)'}
bg={theme.bgDark}
borderRadius="lg"
border="1px solid"
borderColor={theme.border}
@@ -1925,7 +1903,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
key={i}
justify="space-between"
p={2}
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
bg={'rgba(255, 68, 68, 0.05)'}
borderRadius="md"
>
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
@@ -1953,7 +1931,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
key={i}
justify="space-between"
p={2}
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
bg={'rgba(0, 200, 81, 0.05)'}
borderRadius="md"
>
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
@@ -1975,7 +1953,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<HStack mt={3} spacing={2}>
<Text fontSize="sm" color={theme.textMuted}>类型:</Text>
{dayData.info_types && dayData.info_types.map((type, i) => (
<Badge key={i} colorScheme={colorMode === 'light' ? 'blue' : 'yellow'} fontSize="xs">
<Badge key={i} colorScheme={'blue'} fontSize="xs">
{type}
</Badge>
))}
@@ -2002,7 +1980,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<ReactECharts
option={getPledgeOption()}
style={{ height: '100%', width: '100%' }}
theme={colorMode === 'light' ? 'light' : 'dark'}
theme={'light'}
/>
</Box>
)}
@@ -2032,7 +2010,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
<Tbody>
{Array.isArray(pledgeData) && pledgeData.length > 0 ? (
pledgeData.map((item, idx) => (
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
<Tr key={idx} _hover={{ bg: theme.bgDark }}>
<Td color={theme.textPrimary}>{item.end_date}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>

View File

@@ -0,0 +1,273 @@
/**
* StockQuoteCard - 股票行情卡片组件
*
* 展示股票的实时行情、关键指标和主力动态
*/
import React from 'react';
import {
Box,
Card,
CardBody,
Flex,
HStack,
VStack,
Text,
Badge,
Progress,
Skeleton,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { Share2 } from 'lucide-react';
import FavoriteButton from '@components/FavoriteButton';
import type { StockQuoteCardProps } from './types';
import { mockStockQuoteData } from './mockData';
/**
* 格式化价格显示
*/
const formatPrice = (price: number): string => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/**
* 格式化涨跌幅显示
*/
const formatChangePercent = (percent: number): string => {
const sign = percent >= 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
};
/**
* 格式化主力净流入显示
*/
const formatNetInflow = (value: number): string => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}亿`;
};
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
data = mockStockQuoteData,
isLoading = false,
isInWatchlist = false,
isWatchlistLoading = false,
onWatchlistToggle,
onShare,
}) => {
// 处理分享点击
const handleShare = () => {
onShare?.();
};
// 黑金主题颜色配置
const cardBg = '#1A202C';
const borderColor = '#C9A961';
const labelColor = '#C9A961';
const valueColor = '#F4D03F';
const sectionTitleColor = '#F4D03F';
// 涨跌颜色(红涨绿跌)
const upColor = '#F44336'; // 涨 - 红色
const downColor = '#4CAF50'; // 跌 - 绿色
const priceColor = data.changePercent >= 0 ? upColor : downColor;
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
if (isLoading) {
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
<Skeleton height="120px" />
</CardBody>
</Card>
);
}
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
<Flex justify="space-between" align="center" mb={4}>
{/* 左侧:名称(代码) | 指数标签 */}
<HStack spacing={2}>
<Text fontSize="22px" fontWeight="bold" color={valueColor}>
{data.name}{data.code}
</Text>
{data.indexTags.length > 0 && (
<>
<Text color={labelColor} fontSize="22px" fontWeight="light">|</Text>
<Text fontSize="16px" color={labelColor}>
{data.indexTags.join('、')}
</Text>
</>
)}
</HStack>
{/* 右侧:关注 + 分享 + 时间 */}
<HStack spacing={3}>
<FavoriteButton
isFavorite={isInWatchlist}
isLoading={isWatchlistLoading}
onClick={onWatchlistToggle || (() => {})}
colorScheme="gold"
size="sm"
/>
<Tooltip label="分享" placement="top">
<IconButton
aria-label="分享"
icon={<Share2 size={18} />}
variant="ghost"
color={labelColor}
size="sm"
onClick={handleShare}
_hover={{ bg: 'whiteAlpha.100' }}
/>
</Tooltip>
<Text fontSize="14px" color={labelColor}>
{data.updateTime.split(' ')[1]}
</Text>
</HStack>
</Flex>
{/* 三栏布局 */}
<Flex gap={8}>
{/* 左栏:价格信息 */}
<Box flex="1">
<HStack align="baseline" spacing={3} mb={3}>
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
{formatPrice(data.currentPrice)}
</Text>
<Badge
bg={data.changePercent >= 0 ? upColor : downColor}
color="#FFFFFF"
fontSize="20px"
fontWeight="bold"
px={3}
py={1}
borderRadius="md"
>
{formatChangePercent(data.changePercent)}
</Badge>
</HStack>
{/* 次要行情:今开 | 昨收 | 最高 | 最低 */}
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.todayOpen)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.yesterdayClose)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={upColor} fontWeight="bold">
{formatPrice(data.todayHigh)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={downColor} fontWeight="bold">
{formatPrice(data.todayLow)}
</Text>
</Text>
</HStack>
</Box>
{/* 中栏:关键指标 */}
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}>(PE)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pe.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>(PB)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pb.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.marketCap}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>52</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{formatPrice(data.week52Low)}-{formatPrice(data.week52High)}
</Text>
</HStack>
</VStack>
</Box>
{/* 右栏:主力动态 */}
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
{formatNetInflow(data.mainNetInflow)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.institutionHolding.toFixed(2)}%
</Text>
</HStack>
{/* 买卖比例条 */}
<Box mt={1}>
<Progress
value={data.buyRatio}
size="sm"
sx={{
'& > div': { bg: upColor },
}}
bg={downColor}
borderRadius="full"
/>
<HStack justify="space-between" mt={1} fontSize="14px">
<Text color={upColor}>{data.buyRatio}%</Text>
<Text color={downColor}>{data.sellRatio}%</Text>
</HStack>
</Box>
</VStack>
</Box>
</Flex>
</CardBody>
</Card>
);
};
export default StockQuoteCard;

View File

@@ -0,0 +1,38 @@
import type { StockQuoteCardData } from './types';
/**
* 贵州茅台 Mock 数据
*/
export const mockStockQuoteData: StockQuoteCardData = {
// 基础信息
name: '贵州茅台',
code: '600519.SH',
indexTags: ['沪深300'],
// 价格信息
currentPrice: 2178.5,
changePercent: 3.65,
todayOpen: 2156.0,
yesterdayClose: 2101.0,
todayHigh: 2185.0,
todayLow: 2150.0,
// 关键指标
pe: 38.62,
pb: 14.82,
marketCap: '2.73万亿',
week52Low: 1980,
week52High: 2350,
// 主力动态
mainNetInflow: 1.28,
institutionHolding: 72.35,
buyRatio: 85,
sellRatio: 15,
// 更新时间
updateTime: '2025-12-03 14:30:25',
// 自选状态
isFavorite: false,
};

View File

@@ -0,0 +1,54 @@
/**
* StockQuoteCard 组件类型定义
*/
/**
* 股票行情卡片数据
*/
export interface StockQuoteCardData {
// 基础信息
name: string; // 股票名称
code: string; // 股票代码
indexTags: string[]; // 指数标签(如 沪深300、上证50
// 价格信息
currentPrice: number; // 当前价格
changePercent: number; // 涨跌幅(百分比,如 3.65 表示 +3.65%
todayOpen: number; // 今开
yesterdayClose: number; // 昨收
todayHigh: number; // 今日最高
todayLow: number; // 今日最低
// 关键指标
pe: number; // 市盈率
pb: number; // 市净率
marketCap: string; // 流通市值(已格式化,如 "2.73万亿"
week52Low: number; // 52周最低
week52High: number; // 52周最高
// 主力动态
mainNetInflow: number; // 主力净流入(亿)
institutionHolding: number; // 机构持仓比例(百分比)
buyRatio: number; // 买入比例(百分比)
sellRatio: number; // 卖出比例(百分比)
// 更新时间
updateTime: string; // 格式YYYY-MM-DD HH:mm:ss
// 自选状态
isFavorite?: boolean; // 是否已加入自选
}
/**
* StockQuoteCard 组件 Props
*/
export interface StockQuoteCardProps {
data?: StockQuoteCardData;
isLoading?: boolean;
// 自选股相关(与 WatchlistButton 接口保持一致)
isInWatchlist?: boolean; // 是否在自选股中
isWatchlistLoading?: boolean; // 自选股操作加载中
onWatchlistToggle?: () => void; // 自选股切换回调
// 分享
onShare?: () => void; // 分享回调
}

View File

@@ -0,0 +1,55 @@
// src/views/Company/constants/index.js
// 公司详情页面常量配置
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle, FaBrain, FaNewspaper } from 'react-icons/fa';
/**
* Tab 配置
* @type {Array<{key: string, name: string, icon: React.ComponentType}>}
*/
export const COMPANY_TABS = [
{ key: 'overview', name: '公司概览', icon: FaInfoCircle },
{ key: 'analysis', name: '深度分析', icon: FaBrain },
{ key: 'market', name: '股票行情', icon: FaChartLine },
{ key: 'financial', name: '财务全景', icon: FaMoneyBillWave },
{ key: 'forecast', name: '盈利预测', icon: FaChartBar },
{ key: 'tracking', name: '动态跟踪', icon: FaNewspaper },
];
/**
* Tab 选中状态样式
*/
export const TAB_SELECTED_STYLE = {
transform: 'scale(1.02)',
transition: 'all 0.2s',
};
/**
* Toast 消息配置
*/
export const TOAST_MESSAGES = {
WATCHLIST_ADD: { title: '已加入自选', status: 'success', duration: 1500 },
WATCHLIST_REMOVE: { title: '已从自选移除', status: 'info', duration: 1500 },
WATCHLIST_ERROR: { title: '操作失败,请稍后重试', status: 'error', duration: 2000 },
INVALID_CODE: { title: '无效的股票代码', status: 'error', duration: 2000 },
LOGIN_REQUIRED: { title: '请先登录后再加入自选', status: 'warning', duration: 2000 },
};
/**
* 默认股票代码
*/
export const DEFAULT_STOCK_CODE = '000001';
/**
* URL 参数名
*/
export const URL_PARAM_NAME = 'scode';
/**
* 根据索引获取 Tab 名称
* @param {number} index - Tab 索引
* @returns {string} Tab 名称
*/
export const getTabNameByIndex = (index) => {
return COMPANY_TABS[index]?.name || 'Unknown';
};

View File

@@ -0,0 +1,90 @@
// src/views/Company/hooks/useCompanyStock.js
// 股票代码管理 Hook - 处理 URL 参数同步和搜索逻辑
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { DEFAULT_STOCK_CODE, URL_PARAM_NAME } from '../constants';
/**
* 股票代码管理 Hook
*
* 功能:
* - 管理当前股票代码状态
* - 双向同步 URL 参数
* - 处理搜索输入和提交
*
* @param {Object} options - 配置选项
* @param {string} [options.defaultCode] - 默认股票代码
* @param {string} [options.paramName] - URL 参数名
* @param {Function} [options.onStockChange] - 股票代码变化回调 (newCode, prevCode) => void
* @returns {Object} 股票代码状态和操作方法
*/
export const useCompanyStock = (options = {}) => {
const {
defaultCode = DEFAULT_STOCK_CODE,
paramName = URL_PARAM_NAME,
onStockChange,
} = options;
const [searchParams, setSearchParams] = useSearchParams();
// 从 URL 参数初始化股票代码
const [stockCode, setStockCode] = useState(
searchParams.get(paramName) || defaultCode
);
// 输入框状态(未确认的输入)
const [inputCode, setInputCode] = useState(stockCode);
/**
* 监听 URL 参数变化,同步到本地状态
* 支持浏览器前进/后退按钮
*/
useEffect(() => {
const urlCode = searchParams.get(paramName);
if (urlCode && urlCode !== stockCode) {
setStockCode(urlCode);
setInputCode(urlCode);
}
}, [searchParams, paramName, stockCode]);
/**
* 执行搜索 - 更新 stockCode 和 URL
*/
const handleSearch = useCallback(() => {
const trimmedCode = inputCode?.trim();
if (trimmedCode && trimmedCode !== stockCode) {
// 触发变化回调(用于追踪)
onStockChange?.(trimmedCode, stockCode);
// 更新状态
setStockCode(trimmedCode);
// 更新 URL 参数
setSearchParams({ [paramName]: trimmedCode });
}
}, [inputCode, stockCode, paramName, setSearchParams, onStockChange]);
/**
* 处理键盘事件 - 回车键触发搜索
*/
const handleKeyPress = useCallback((e) => {
if (e.key === 'Enter') {
handleSearch();
}
}, [handleSearch]);
return {
// 状态
stockCode, // 当前确认的股票代码
inputCode, // 输入框中的值(未确认)
// 操作方法
setInputCode, // 更新输入框
handleSearch, // 执行搜索
handleKeyPress, // 处理回车键
};
};
export default useCompanyStock;

View File

@@ -0,0 +1,166 @@
// src/views/Company/hooks/useCompanyWatchlist.js
// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useToast } from '@chakra-ui/react';
import { useAuth } from '@contexts/AuthContext';
import { logger } from '@utils/logger';
import {
loadWatchlist,
toggleWatchlist,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} from '@store/slices/stockSlice';
import { TOAST_MESSAGES } from '../constants';
/**
* Company 页面自选股管理 Hook
*
* 功能:
* - 检查当前股票是否在自选股中
* - 提供添加/移除自选股功能
* - 与 Redux stockSlice 同步
*
* @param {Object} options - 配置选项
* @param {string} options.stockCode - 当前股票代码
* @param {Object} [options.tracking] - 追踪回调
* @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调
* @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调
* @returns {Object} 自选股状态和操作方法
*/
export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => {
const dispatch = useDispatch();
const toast = useToast();
const { isAuthenticated } = useAuth();
// 从 Redux 获取自选股列表
const watchlist = useSelector((state) => state.stock.watchlist);
const watchlistLoading = useSelector((state) => state.stock.loading.watchlist);
// 追踪是否已初始化(防止无限循环)
const hasInitializedRef = useRef(false);
/**
* 派生状态:判断当前股票是否在自选股中
* 使用 useMemo 避免重复计算
*/
const isInWatchlist = useMemo(() => {
if (!stockCode || !Array.isArray(watchlist)) {
return false;
}
// 标准化股票代码提取6位数字
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
const targetCode = normalize(stockCode);
return watchlist.some((item) => normalize(item.stock_code) === targetCode);
}, [watchlist, stockCode]);
/**
* 初始化:加载自选股列表
* 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股)
*/
useEffect(() => {
if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) {
hasInitializedRef.current = true;
dispatch(loadWatchlist());
}
}, [isAuthenticated, watchlistLoading, dispatch]);
/**
* 切换自选股状态(乐观更新模式)
* 1. 立即更新 UI无 loading
* 2. 后台静默请求 API
* 3. 失败时回滚并提示
*/
const toggle = useCallback(async () => {
// 参数校验
if (!stockCode) {
logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode });
toast(TOAST_MESSAGES.INVALID_CODE);
return;
}
// 权限校验
if (!isAuthenticated) {
logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode });
toast(TOAST_MESSAGES.LOGIN_REQUIRED);
return;
}
// 标准化股票代码用于匹配
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
const targetCode = normalize(stockCode);
// 从 watchlist 中找到原始 stock_code保持与后端数据结构一致
const matchedItem = watchlist.find(
item => normalize(item.stock_code) === targetCode
);
// 移除时使用原始 stock_code添加时使用传入的 stockCode
const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode;
// 保存当前状态用于回滚
const wasInWatchlist = isInWatchlist;
logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', {
stockCode,
codeForApi,
wasInWatchlist,
action: wasInWatchlist ? 'remove' : 'add',
});
// 1. 乐观更新:立即更新 UI不显示 loading
if (wasInWatchlist) {
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
} else {
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
}
try {
// 2. 后台静默请求 API
await dispatch(
toggleWatchlist({
stockCode: codeForApi,
stockName: matchedItem?.stock_name || '',
isInWatchlist: wasInWatchlist,
})
).unwrap();
// 3. 成功:触发追踪回调(不显示 toast状态已更新
if (wasInWatchlist) {
tracking.onRemove?.(stockCode);
} else {
tracking.onAdd?.(stockCode);
}
} catch (error) {
// 4. 失败:回滚状态 + 显示错误提示
logger.error('useCompanyWatchlist', 'toggle', error, {
stockCode,
wasInWatchlist,
});
// 回滚操作
if (wasInWatchlist) {
// 之前在自选中,乐观删除了,现在要恢复
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
} else {
// 之前不在自选中,乐观添加了,现在要移除
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
}
toast(TOAST_MESSAGES.WATCHLIST_ERROR);
}
}, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]);
return {
// 状态
isInWatchlist, // 是否在自选股中
isLoading: watchlistLoading, // 仅初始加载时显示 loading乐观更新模式
// 操作方法
toggle, // 切换自选状态
};
};
export default useCompanyWatchlist;

View File

@@ -1,51 +1,39 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Container,
Heading,
Card,
CardBody,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
HStack,
VStack,
Input,
Button,
InputGroup,
InputLeftElement,
Text,
Badge,
Divider,
Icon,
useColorModeValue,
useColorMode,
IconButton,
useToast,
} from '@chakra-ui/react';
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import FinancialPanorama from './FinancialPanorama';
import ForecastReport from './ForecastReport';
import MarketDataView from './MarketDataView';
import CompanyOverview from './CompanyOverview';
// 导入 PostHog 追踪 Hook
// src/views/Company/index.js
// 公司详情页面入口 - 纯组合层
import React, { useEffect, useRef } from 'react';
import { Container, VStack } from '@chakra-ui/react';
// 自定义 Hooks
import { useCompanyStock } from './hooks/useCompanyStock';
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
import { useCompanyEvents } from './hooks/useCompanyEvents';
const CompanyIndex = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
const [inputCode, setInputCode] = useState(stockCode);
const { colorMode, toggleColorMode } = useColorMode();
const toast = useToast();
const { isAuthenticated } = useAuth();
// 页面组件
import CompanyHeader from './components/CompanyHeader';
import StockQuoteCard from './components/StockQuoteCard';
import CompanyTabs from './components/CompanyTabs';
// 🎯 PostHog 事件追踪
/**
* 公司详情页面
*
* 功能:
* - 股票搜索与代码管理
* - 自选股添加/移除
* - 多维度数据展示(概览、行情、财务、预测)
* - PostHog 事件追踪
*/
const CompanyIndex = () => {
// 1. 先获取股票代码(不带追踪回调)
const {
stockCode,
inputCode,
setInputCode,
handleSearch,
handleKeyPress,
} = useCompanyStock();
// 2. 再初始化事件追踪(传入 stockCode
const {
trackStockSearched,
trackTabChanged,
@@ -53,297 +41,52 @@ const CompanyIndex = () => {
trackWatchlistRemoved,
} = useCompanyEvents({ stockCode });
// Tab 索引状态(用于追踪 Tab 切换)
const [currentTabIndex, setCurrentTabIndex] = useState(0);
// 3. 自选股管理
const {
isInWatchlist,
isLoading: isWatchlistLoading,
toggle: handleWatchlistToggle,
} = useCompanyWatchlist({
stockCode,
tracking: {
onAdd: trackWatchlistAdded,
onRemove: trackWatchlistRemoved,
},
});
const bgColor = useColorModeValue('white', 'gray.800');
const tabBg = useColorModeValue('gray.50', 'gray.700');
const activeBg = useColorModeValue('blue.500', 'blue.400');
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
const loadWatchlistStatus = useCallback(async () => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist', {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (!resp.ok) {
setIsInWatchlist(false);
return;
}
const data = await resp.json();
const list = Array.isArray(data?.data) ? data.data : [];
const codes = new Set(list.map((item) => item.stock_code));
setIsInWatchlist(codes.has(stockCode));
} catch (e) {
setIsInWatchlist(false);
}
}, [stockCode]);
// 当URL参数变化时更新股票代码
// 4. 监听 stockCode 变化,触发搜索追踪
const prevStockCodeRef = useRef(stockCode);
useEffect(() => {
const scode = searchParams.get('scode');
if (scode && scode !== stockCode) {
setStockCode(scode);
setInputCode(scode);
if (stockCode !== prevStockCodeRef.current) {
trackStockSearched(stockCode, prevStockCodeRef.current);
prevStockCodeRef.current = stockCode;
}
}, [searchParams, stockCode]);
useEffect(() => {
loadWatchlistStatus();
}, [loadWatchlistStatus]);
const handleSearch = () => {
if (inputCode && inputCode !== stockCode) {
// 🎯 追踪股票搜索
trackStockSearched(inputCode, stockCode);
setStockCode(inputCode);
setSearchParams({ scode: inputCode });
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleWatchlistToggle = async () => {
if (!stockCode) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '无效的股票代码', { stockCode });
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
return;
}
if (!isAuthenticated) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '用户未登录', { stockCode });
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
return;
}
try {
setIsWatchlistLoading(true);
const base = getApiBase();
if (isInWatchlist) {
logger.debug('CompanyIndex', '准备从自选移除', { stockCode });
const url = base + `/api/account/watchlist/${stockCode}`;
logger.api.request('DELETE', url, { stockCode });
const resp = await fetch(url, {
method: 'DELETE',
credentials: 'include'
});
logger.api.response('DELETE', url, resp.status);
if (!resp.ok) throw new Error('删除失败');
// 🎯 追踪移除自选
trackWatchlistRemoved(stockCode);
setIsInWatchlist(false);
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
} else {
logger.debug('CompanyIndex', '准备添加到自选', { stockCode });
const url = base + '/api/account/watchlist';
const body = { stock_code: stockCode };
logger.api.request('POST', url, body);
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
logger.api.response('POST', url, resp.status);
if (!resp.ok) throw new Error('添加失败');
// 🎯 追踪加入自选
trackWatchlistAdded(stockCode);
setIsInWatchlist(true);
toast({ title: '已加入自选', status: 'success', duration: 1500 });
}
} catch (error) {
logger.error('CompanyIndex', 'handleWatchlistToggle', error, { stockCode, isInWatchlist });
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
} finally {
setIsWatchlistLoading(false);
}
};
}, [stockCode, trackStockSearched]);
return (
<Container maxW="container.xl" py={5}>
{/* 页面标题和股票搜索 */}
<VStack align="stretch" spacing={5}>
<Card bg={bgColor} shadow="md">
<CardBody>
<HStack justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="lg">个股详情</Heading>
<Text color="gray.600" fontSize="sm">
查看股票实时行情财务数据和盈利预测
</Text>
</VStack>
<HStack spacing={3}>
<InputGroup size="lg" maxW="300px">
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
placeholder="输入股票代码"
value={inputCode}
onChange={(e) => setInputCode(e.target.value)}
onKeyPress={handleKeyPress}
borderRadius="md"
_focus={{
borderColor: 'blue.500',
boxShadow: '0 0 0 1px #3182ce'
}}
/>
</InputGroup>
<Button
colorScheme="blue"
size="lg"
onClick={handleSearch}
leftIcon={<SearchIcon />}
>
查询
</Button>
<Button
colorScheme={isInWatchlist ? 'yellow' : 'teal'}
variant={isInWatchlist ? 'solid' : 'outline'}
size="lg"
onClick={handleWatchlistToggle}
leftIcon={<StarIcon />}
isLoading={isWatchlistLoading}
>
{isInWatchlist ? '已在自选' : '加入自选'}
</Button>
<IconButton
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
variant="outline"
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
size="lg"
aria-label="Toggle color mode"
/>
</HStack>
</HStack>
{/* 当前股票信息 */}
<HStack mt={4} spacing={4}>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
股票代码: {stockCode}
</Badge>
<Text fontSize="sm" color="gray.600">
更新时间: {new Date().toLocaleString()}
</Text>
</HStack>
</CardBody>
</Card>
{/* 数据展示区域 */}
<Card bg={bgColor} shadow="lg">
<CardBody p={0}>
<Tabs
variant="soft-rounded"
colorScheme="blue"
size="lg"
index={currentTabIndex}
onChange={(index) => {
const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测'];
// 🎯 追踪 Tab 切换
trackTabChanged(index, tabNames[index], currentTabIndex);
setCurrentTabIndex(index);
}}
>
<TabList p={4} bg={tabBg}>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaInfoCircle} />
<Text>公司概览</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaChartLine} />
<Text>股票行情</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaMoneyBillWave} />
<Text>财务全景</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
>
<HStack spacing={2}>
<Icon as={FaChartBar} />
<Text>盈利预测</Text>
</HStack>
</Tab>
</TabList>
<Divider />
<TabPanels>
<TabPanel p={6}>
<CompanyOverview stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<MarketDataView stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<FinancialPanorama stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<ForecastReport stockCode={stockCode} />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
<Container maxW="container.xl" py={0} bg='#1A202C'>
<VStack align="stretch" spacing={0}>
{/* 页面头部:标题、搜索 */}
<CompanyHeader
inputCode={inputCode}
onInputChange={setInputCode}
onSearch={handleSearch}
onKeyPress={handleKeyPress}
bgColor="#1A202C"
/>
{/* 股票行情卡片:价格、关键指标、主力动态、自选股按钮 */}
<StockQuoteCard
isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={handleWatchlistToggle}
/>
{/* Tab 切换区域:概览、行情、财务、预测 */}
<CompanyTabs stockCode={stockCode} onTabChange={trackTabChanged} bgColor="#1A202C"/>
</VStack>
</Container>
);
};
export default CompanyIndex;