Compare commits

...

6 Commits

Author SHA1 Message Date
zdl
bf8847698b feat: CompanyOverview TypeScript 类型定义和工具函数
- types.ts: 添加公司基本信息、股东、管理层等接口定义
- utils.ts: 添加注册资本、日期格式化函数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:02:24 +08:00
zdl
7c83ffe008 perf: loadWatchlist 添加 localStorage 缓存(7天有效期)
- 添加 loadWatchlistFromCache/saveWatchlistToCache 缓存工具函数
- loadWatchlist 三级缓存策略:Redux → localStorage → API
- toggleWatchlist 成功后自动同步更新缓存
- 减少重复 API 请求,提升页面加载性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:01:33 +08:00
zdl
8786fa7b06 feat: StockQuoteCard 根据股票代码获取真实行情数据
- 新增 useStockQuote Hook 获取股票行情
- Company 页面使用 Hook 并传递数据给 StockQuoteCard
- StockQuoteCard 处理 null 数据显示骨架屏
- 股票代码变化时自动刷新行情数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:00:03 +08:00
zdl
0997cd9992 feat: 搜索栏交互优化 - 移除查询按钮,选择后直接跳转
- SearchBar: 移除"查询"按钮,简化交互
- SearchBar: 选择股票后直接触发搜索跳转
- useCompanyStock: handleSearch 支持直接传入股票代码参数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:52:04 +08:00
zdl
c8d704363d fix: 搜索框默认值改为空,避免下拉弹窗自动打开
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:35:01 +08:00
zdl
0de4a1f7af feat: SearchBar 模糊搜索功能
- SearchBar: 添加股票代码/名称模糊搜索下拉列表
- SearchBar: 使用 Redux allStocks 数据源进行过滤
- SearchBar: 点击外部自动关闭下拉,选择后自动搜索
- useCompanyStock: handleKeyPress 改为 handleKeyDown(兼容性优化)
- Company/index: 初始化时加载全部股票列表

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:25:31 +08:00
9 changed files with 470 additions and 49 deletions

View File

@@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig'; import { getApiBase } from '../../utils/apiConfig';
// ==================== Watchlist 缓存配置 ====================
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
/**
* 从 localStorage 读取自选股缓存
*/
const loadWatchlistFromCache = () => {
try {
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期7天
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
localStorage.removeItem(WATCHLIST_CACHE_KEY);
logger.debug('stockSlice', '自选股缓存已过期');
return null;
}
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
});
return data;
} catch (error) {
logger.error('stockSlice', 'loadWatchlistFromCache', error);
return null;
}
};
/**
* 保存自选股到 localStorage
*/
const saveWatchlistToCache = (data) => {
try {
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
count: data?.length || 0
});
} catch (error) {
logger.error('stockSlice', 'saveWatchlistToCache', error);
}
};
// ==================== Async Thunks ==================== // ==================== Async Thunks ====================
/** /**
@@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk(
/** /**
* 加载用户自选股列表(包含完整信息) * 加载用户自选股列表(包含完整信息)
* 缓存策略Redux 内存缓存 → localStorage 持久缓存7天 → API 请求
*/ */
export const loadWatchlist = createAsyncThunk( export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist', 'stock/loadWatchlist',
async () => { async (_, { getState }) => {
logger.debug('stockSlice', 'loadWatchlist'); logger.debug('stockSlice', 'loadWatchlist');
try { try {
// 1. 先检查 Redux 内存缓存
const reduxCached = getState().stock.watchlist;
if (reduxCached && reduxCached.length > 0) {
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
return reduxCached;
}
// 2. 再检查 localStorage 持久缓存7天有效期
const localCached = loadWatchlistFromCache();
if (localCached && localCached.length > 0) {
return localCached;
}
// 3. 缓存无效,调用 API
const apiBase = getApiBase(); const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist`, { const response = await fetch(`${apiBase}/api/account/watchlist`, {
credentials: 'include' credentials: 'include'
@@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk(
stock_code: item.stock_code, stock_code: item.stock_code,
stock_name: item.stock_name, stock_name: item.stock_name,
})); }));
// 保存到 localStorage 缓存
saveWatchlistToCache(watchlistData);
logger.debug('stockSlice', '自选股列表加载成功', { logger.debug('stockSlice', '自选股列表加载成功', {
count: watchlistData.length count: watchlistData.length
}); });
@@ -490,9 +559,10 @@ const stockSlice = createSlice({
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
} }
}) })
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作 // fulfilled: 同步更新 localStorage 缓存
.addCase(toggleWatchlist.fulfilled, () => { .addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新 // 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist);
}); });
} }
}); });

View File

@@ -1,42 +1,97 @@
// src/views/Company/components/CompanyHeader/SearchBar.js // src/views/Company/components/CompanyHeader/SearchBar.js
// 股票搜索栏组件 - 金色主题 // 股票搜索栏组件 - 金色主题 + 模糊搜索下拉
import React from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { import {
Box,
HStack, HStack,
Input, Input,
Button,
InputGroup, InputGroup,
InputLeftElement, InputLeftElement,
Text,
VStack,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons'; import { SearchIcon } from '@chakra-ui/icons';
/** /**
* 股票搜索栏组件 * 股票搜索栏组件(带模糊搜索下拉)
* *
* @param {Object} props * @param {Object} props
* @param {string} props.inputCode - 输入框当前值 * @param {string} props.inputCode - 输入框当前值
* @param {Function} props.onInputChange - 输入变化回调 * @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索按钮点击回调 * @param {Function} props.onSearch - 搜索按钮点击回调
* @param {Function} props.onKeyPress - 键盘事件回调 * @param {Function} props.onKeyDown - 键盘事件回调
*/ */
const SearchBar = ({ const SearchBar = ({
inputCode, inputCode,
onInputChange, onInputChange,
onSearch, onSearch,
onKeyPress, onKeyDown,
}) => { }) => {
// 下拉状态
const [showDropdown, setShowDropdown] = useState(false);
const [filteredStocks, setFilteredStocks] = useState([]);
const containerRef = useRef(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks);
// 模糊搜索过滤
useEffect(() => {
if (inputCode && inputCode.trim()) {
const searchTerm = inputCode.trim().toLowerCase();
const filtered = allStocks.filter(stock =>
stock.code.toLowerCase().includes(searchTerm) ||
stock.name.includes(inputCode.trim())
).slice(0, 10); // 限制显示10条
setFilteredStocks(filtered);
setShowDropdown(filtered.length > 0);
} else {
setFilteredStocks([]);
setShowDropdown(false);
}
}, [inputCode, allStocks]);
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 选择股票 - 直接触发搜索跳转
const handleSelectStock = (stock) => {
onInputChange(stock.code);
setShowDropdown(false);
onSearch(stock.code);
};
// 处理键盘事件
const handleKeyDownWrapper = (e) => {
if (e.key === 'Enter') {
setShowDropdown(false);
}
onKeyDown?.(e);
};
return ( return (
<HStack spacing={3}> <Box ref={containerRef} position="relative" w="300px">
<InputGroup size="lg" maxW="300px"> <InputGroup size="lg">
<InputLeftElement pointerEvents="none"> <InputLeftElement pointerEvents="none">
<SearchIcon color="#C9A961" /> <SearchIcon color="#C9A961" />
</InputLeftElement> </InputLeftElement>
<Input <Input
placeholder="输入股票代码" placeholder="输入股票代码或名称"
value={inputCode} value={inputCode}
onChange={(e) => onInputChange(e.target.value)} onChange={(e) => onInputChange(e.target.value)}
onKeyPress={onKeyPress} onKeyDown={handleKeyDownWrapper}
onFocus={() => inputCode && filteredStocks.length > 0 && setShowDropdown(true)}
borderRadius="md" borderRadius="md"
color="white" color="white"
borderColor="#C9A961" borderColor="#C9A961"
@@ -50,19 +105,50 @@ const SearchBar = ({
}} }}
/> />
</InputGroup> </InputGroup>
<Button
size="lg" {/* 模糊搜索下拉列表 */}
onClick={onSearch} {showDropdown && (
leftIcon={<SearchIcon />} <Box
position="absolute"
top="100%"
left={0}
mt={1}
w="100%"
bg="#1A202C" bg="#1A202C"
color="#C9A961" border="1px solid #C9A961"
borderWidth="1px" borderRadius="md"
borderColor="#C9A961" maxH="300px"
_hover={{ bg: '#1a1a1a', borderColor: '#F4D03F', color: '#F4D03F' }} overflowY="auto"
zIndex={1000}
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
> >
查询 <VStack align="stretch" spacing={0}>
</Button> {filteredStocks.map((stock) => (
<Box
key={stock.code}
px={4}
py={2}
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => handleSelectStock(stock)}
borderBottom="1px solid"
borderColor="whiteAlpha.100"
_last={{ borderBottom: 'none' }}
>
<HStack justify="space-between">
<Text color="#F4D03F" fontWeight="bold" fontSize="sm">
{stock.code}
</Text>
<Text color="#C9A961" fontSize="sm" noOfLines={1} maxW="180px">
{stock.name}
</Text>
</HStack> </HStack>
</Box>
))}
</VStack>
</Box>
)}
</Box>
); );
}; };

View File

@@ -18,20 +18,20 @@ import SearchBar from './SearchBar';
* *
* 包含: * 包含:
* - 页面标题和描述(金色主题) * - 页面标题和描述(金色主题)
* - 股票搜索栏 * - 股票搜索栏(支持模糊搜索)
* *
* @param {Object} props * @param {Object} props
* @param {string} props.inputCode - 搜索输入框值 * @param {string} props.inputCode - 搜索输入框值
* @param {Function} props.onInputChange - 输入变化回调 * @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索回调 * @param {Function} props.onSearch - 搜索回调
* @param {Function} props.onKeyPress - 键盘事件回调 * @param {Function} props.onKeyDown - 键盘事件回调
* @param {string} props.bgColor - 背景颜色 * @param {string} props.bgColor - 背景颜色
*/ */
const CompanyHeader = ({ const CompanyHeader = ({
inputCode, inputCode,
onInputChange, onInputChange,
onSearch, onSearch,
onKeyPress, onKeyDown,
bgColor, bgColor,
}) => { }) => {
return ( return (
@@ -51,7 +51,7 @@ const CompanyHeader = ({
inputCode={inputCode} inputCode={inputCode}
onInputChange={onInputChange} onInputChange={onInputChange}
onSearch={onSearch} onSearch={onSearch}
onKeyPress={onKeyPress} onKeyDown={onKeyDown}
/> />
</HStack> </HStack>
</CardBody> </CardBody>

View File

@@ -0,0 +1,118 @@
// src/views/Company/components/CompanyOverview/types.ts
// 公司概览组件类型定义
/**
* 公司基本信息
*/
export interface BasicInfo {
ORGNAME?: string;
SECNAME?: string;
SECCODE?: string;
sw_industry_l1?: string;
sw_industry_l2?: string;
sw_industry_l3?: string;
legal_representative?: string;
chairman?: string;
general_manager?: string;
establish_date?: string;
reg_capital?: number;
province?: string;
city?: string;
website?: string;
email?: string;
tel?: string;
company_intro?: string;
}
/**
* 实际控制人
*/
export interface ActualControl {
controller_name?: string;
controller_type?: string;
holding_ratio?: number;
}
/**
* 股权集中度
*/
export interface Concentration {
top1_ratio?: number;
top5_ratio?: number;
top10_ratio?: number;
}
/**
* 管理层信息
*/
export interface Management {
name?: string;
position?: string;
start_date?: string;
end_date?: string;
}
/**
* 股东信息
*/
export interface Shareholder {
shareholder_name?: string;
holding_ratio?: number;
holding_amount?: number;
}
/**
* 分支机构
*/
export interface Branch {
branch_name?: string;
address?: string;
}
/**
* 公告信息
*/
export interface Announcement {
title?: string;
publish_date?: string;
url?: string;
}
/**
* 披露计划
*/
export interface DisclosureSchedule {
report_type?: string;
disclosure_date?: string;
}
/**
* useCompanyOverviewData Hook 返回值
*/
export interface CompanyOverviewData {
basicInfo: BasicInfo | null;
actualControl: ActualControl[];
concentration: Concentration[];
management: Management[];
topCirculationShareholders: Shareholder[];
topShareholders: Shareholder[];
branches: Branch[];
announcements: Announcement[];
disclosureSchedule: DisclosureSchedule[];
loading: boolean;
dataLoaded: boolean;
}
/**
* CompanyOverview 组件 Props
*/
export interface CompanyOverviewProps {
stockCode?: string;
}
/**
* CompanyHeaderCard 组件 Props
*/
export interface CompanyHeaderCardProps {
basicInfo: BasicInfo;
}

View File

@@ -0,0 +1,26 @@
// src/views/Company/components/CompanyOverview/utils.ts
// 公司概览格式化工具函数
/**
* 格式化注册资本
* @param value - 注册资本(万元)
* @returns 格式化后的字符串
*/
export const formatRegisteredCapital = (value: number | null | undefined): string => {
if (!value && value !== 0) return "-";
const absValue = Math.abs(value);
if (absValue >= 100000) {
return (value / 10000).toFixed(2) + "亿元";
}
return value.toFixed(2) + "万元";
};
/**
* 格式化日期
* @param dateString - 日期字符串
* @returns 格式化后的日期字符串
*/
export const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("zh-CN");
};

View File

@@ -23,7 +23,6 @@ import { Share2 } from 'lucide-react';
import FavoriteButton from '@components/FavoriteButton'; import FavoriteButton from '@components/FavoriteButton';
import type { StockQuoteCardProps } from './types'; import type { StockQuoteCardProps } from './types';
import { mockStockQuoteData } from './mockData';
/** /**
* 格式化价格显示 * 格式化价格显示
@@ -52,7 +51,7 @@ const formatNetInflow = (value: number): string => {
}; };
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
data = mockStockQuoteData, data,
isLoading = false, isLoading = false,
isInWatchlist = false, isInWatchlist = false,
isWatchlistLoading = false, isWatchlistLoading = false,
@@ -74,19 +73,25 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
// 涨跌颜色(红涨绿跌) // 涨跌颜色(红涨绿跌)
const upColor = '#F44336'; // 涨 - 红色 const upColor = '#F44336'; // 涨 - 红色
const downColor = '#4CAF50'; // 跌 - 绿色 const downColor = '#4CAF50'; // 跌 - 绿色
const priceColor = data.changePercent >= 0 ? upColor : downColor;
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
if (isLoading) { // 加载中或无数据时显示骨架屏
if (isLoading || !data) {
return ( return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}> <Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody> <CardBody>
<Skeleton height="120px" /> <VStack spacing={4} align="stretch">
<Skeleton height="30px" width="200px" />
<Skeleton height="60px" />
<Skeleton height="80px" />
</VStack>
</CardBody> </CardBody>
</Card> </Card>
); );
} }
const priceColor = data.changePercent >= 0 ? upColor : downColor;
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
return ( return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}> <Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody> <CardBody>
@@ -97,7 +102,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
<Text fontSize="22px" fontWeight="bold" color={valueColor}> <Text fontSize="22px" fontWeight="bold" color={valueColor}>
{data.name}{data.code} {data.name}{data.code}
</Text> </Text>
{data.indexTags.length > 0 && ( {data.indexTags?.length > 0 && (
<> <>
<Text color={labelColor} fontSize="22px" fontWeight="light">|</Text> <Text color={labelColor} fontSize="22px" fontWeight="light">|</Text>
<Text fontSize="16px" color={labelColor}> <Text fontSize="16px" color={labelColor}>
@@ -128,7 +133,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
/> />
</Tooltip> </Tooltip>
<Text fontSize="14px" color={labelColor}> <Text fontSize="14px" color={labelColor}>
{data.updateTime.split(' ')[1]} {data.updateTime?.split(' ')[1] || '--:--'}
</Text> </Text>
</HStack> </HStack>
</Flex> </Flex>

View File

@@ -33,8 +33,8 @@ export const useCompanyStock = (options = {}) => {
searchParams.get(paramName) || defaultCode searchParams.get(paramName) || defaultCode
); );
// 输入框状态(未确认的输入 // 输入框状态(默认为空,不显示默认股票代码
const [inputCode, setInputCode] = useState(stockCode); const [inputCode, setInputCode] = useState('');
/** /**
* 监听 URL 参数变化,同步到本地状态 * 监听 URL 参数变化,同步到本地状态
@@ -50,9 +50,10 @@ export const useCompanyStock = (options = {}) => {
/** /**
* 执行搜索 - 更新 stockCode 和 URL * 执行搜索 - 更新 stockCode 和 URL
* @param {string} [code] - 可选,直接传入股票代码(用于下拉选择)
*/ */
const handleSearch = useCallback(() => { const handleSearch = useCallback((code) => {
const trimmedCode = inputCode?.trim(); const trimmedCode = code || inputCode?.trim();
if (trimmedCode && trimmedCode !== stockCode) { if (trimmedCode && trimmedCode !== stockCode) {
// 触发变化回调(用于追踪) // 触发变化回调(用于追踪)
@@ -69,7 +70,7 @@ export const useCompanyStock = (options = {}) => {
/** /**
* 处理键盘事件 - 回车键触发搜索 * 处理键盘事件 - 回车键触发搜索
*/ */
const handleKeyPress = useCallback((e) => { const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSearch(); handleSearch();
} }
@@ -83,7 +84,7 @@ export const useCompanyStock = (options = {}) => {
// 操作方法 // 操作方法
setInputCode, // 更新输入框 setInputCode, // 更新输入框
handleSearch, // 执行搜索 handleSearch, // 执行搜索
handleKeyPress, // 处理回车键 handleKeyDown, // 处理回车键(改用 onKeyDown
}; };
}; };

View File

@@ -0,0 +1,100 @@
// src/views/Company/hooks/useStockQuote.js
// 股票行情数据获取 Hook
import { useState, useEffect } from 'react';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 将 API 响应数据转换为 StockQuoteCard 所需格式
*/
const transformQuoteData = (apiData, stockCode) => {
if (!apiData) return null;
return {
// 基础信息
name: apiData.name || apiData.stock_name || '未知',
code: apiData.code || apiData.stock_code || stockCode,
indexTags: apiData.index_tags || apiData.indexTags || [],
// 价格信息
currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0,
changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0,
todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0,
yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0,
todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0,
todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0,
// 关键指标
pe: apiData.pe || apiData.pe_ttm || 0,
pb: apiData.pb || apiData.pb_mrq || 0,
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
week52Low: apiData.week52_low || apiData.week52Low || 0,
week52High: apiData.week52_high || apiData.week52High || 0,
// 主力动态
mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0,
institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0,
buyRatio: apiData.buy_ratio || apiData.buyRatio || 50,
sellRatio: apiData.sell_ratio || apiData.sellRatio || 50,
// 更新时间
updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(),
};
};
/**
* 股票行情数据获取 Hook
*
* @param {string} stockCode - 股票代码
* @returns {Object} { data, isLoading, error, refetch }
*/
export const useStockQuote = (stockCode) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!stockCode) {
setData(null);
return;
}
const fetchQuote = async () => {
setIsLoading(true);
setError(null);
try {
logger.debug('useStockQuote', '获取股票行情', { stockCode });
const quotes = await stockService.getQuotes([stockCode]);
// API 返回格式: { [stockCode]: quoteData }
const quoteData = quotes?.[stockCode] || quotes;
const transformedData = transformQuoteData(quoteData, stockCode);
logger.debug('useStockQuote', '行情数据转换完成', { stockCode, hasData: !!transformedData });
setData(transformedData);
} catch (err) {
logger.error('useStockQuote', '获取行情失败', err);
setError(err);
setData(null);
} finally {
setIsLoading(false);
}
};
fetchQuote();
}, [stockCode]);
// 手动刷新
const refetch = () => {
if (stockCode) {
setData(null);
// 触发 useEffect 重新执行
}
};
return { data, isLoading, error, refetch };
};
export default useStockQuote;

View File

@@ -3,11 +3,14 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Container, VStack } from '@chakra-ui/react'; import { Container, VStack } from '@chakra-ui/react';
import { useDispatch } from 'react-redux';
import { loadAllStocks } from '@store/slices/stockSlice';
// 自定义 Hooks // 自定义 Hooks
import { useCompanyStock } from './hooks/useCompanyStock'; import { useCompanyStock } from './hooks/useCompanyStock';
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist'; import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
import { useCompanyEvents } from './hooks/useCompanyEvents'; import { useCompanyEvents } from './hooks/useCompanyEvents';
import { useStockQuote } from './hooks/useStockQuote';
// 页面组件 // 页面组件
import CompanyHeader from './components/CompanyHeader'; import CompanyHeader from './components/CompanyHeader';
@@ -24,16 +27,26 @@ import CompanyTabs from './components/CompanyTabs';
* - PostHog 事件追踪 * - PostHog 事件追踪
*/ */
const CompanyIndex = () => { const CompanyIndex = () => {
const dispatch = useDispatch();
// 1. 先获取股票代码(不带追踪回调) // 1. 先获取股票代码(不带追踪回调)
const { const {
stockCode, stockCode,
inputCode, inputCode,
setInputCode, setInputCode,
handleSearch, handleSearch,
handleKeyPress, handleKeyDown,
} = useCompanyStock(); } = useCompanyStock();
// 2. 再初始化事件追踪(传入 stockCode // 加载全部股票列表(用于模糊搜索
useEffect(() => {
dispatch(loadAllStocks());
}, [dispatch]);
// 2. 获取股票行情数据
const { data: quoteData, isLoading: isQuoteLoading } = useStockQuote(stockCode);
// 3. 再初始化事件追踪(传入 stockCode
const { const {
trackStockSearched, trackStockSearched,
trackTabChanged, trackTabChanged,
@@ -71,19 +84,21 @@ const CompanyIndex = () => {
inputCode={inputCode} inputCode={inputCode}
onInputChange={setInputCode} onInputChange={setInputCode}
onSearch={handleSearch} onSearch={handleSearch}
onKeyPress={handleKeyPress} onKeyDown={handleKeyDown}
bgColor="#1A202C" bgColor="#1A202C"
/> />
{/* 股票行情卡片:价格、关键指标、主力动态、自选股按钮 */} {/* 股票行情卡片:价格、关键指标、主力动态、自选股按钮 */}
<StockQuoteCard <StockQuoteCard
data={quoteData}
isLoading={isQuoteLoading}
isInWatchlist={isInWatchlist} isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading} isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={handleWatchlistToggle} onWatchlistToggle={handleWatchlistToggle}
/> />
{/* Tab 切换区域:概览、行情、财务、预测 */} {/* Tab 切换区域:概览、行情、财务、预测 */}
<CompanyTabs stockCode={stockCode} onTabChange={trackTabChanged} bgColor="#1A202C"/> {/* <CompanyTabs stockCode={stockCode} onTabChange={trackTabChanged} bgColor="#1A202C"/> */}
</VStack> </VStack>
</Container> </Container>
); );