Compare commits
6 Commits
3382dd1036
...
bf8847698b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf8847698b | ||
|
|
7c83ffe008 | ||
|
|
8786fa7b06 | ||
|
|
0997cd9992 | ||
|
|
c8d704363d | ||
|
|
0de4a1f7af |
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
118
src/views/Company/components/CompanyOverview/types.ts
Normal file
118
src/views/Company/components/CompanyOverview/types.ts
Normal 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;
|
||||||
|
}
|
||||||
26
src/views/Company/components/CompanyOverview/utils.ts
Normal file
26
src/views/Company/components/CompanyOverview/utils.ts
Normal 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");
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
100
src/views/Company/hooks/useStockQuote.js
Normal file
100
src/views/Company/hooks/useStockQuote.js
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user