update pay ui
This commit is contained in:
@@ -1,62 +0,0 @@
|
|||||||
// 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.onKeyDown - 键盘事件回调
|
|
||||||
* @param {string} props.bgColor - 背景颜色
|
|
||||||
*/
|
|
||||||
const CompanyHeader = ({
|
|
||||||
inputCode,
|
|
||||||
onInputChange,
|
|
||||||
onSearch,
|
|
||||||
onKeyDown,
|
|
||||||
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}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CompanyHeader;
|
|
||||||
288
src/views/Company/components/CompanyHeader/index.tsx
Normal file
288
src/views/Company/components/CompanyHeader/index.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* Company 页面顶部搜索栏组件
|
||||||
|
* - 显示股票代码、名称、价格、涨跌幅
|
||||||
|
* - 股票搜索功能
|
||||||
|
* - 自选股操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
Badge,
|
||||||
|
Skeleton,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { AutoComplete, Spin } from 'antd';
|
||||||
|
import { Search, Star } from 'lucide-react';
|
||||||
|
import { useStockSearch } from '@hooks/useStockSearch';
|
||||||
|
import { THEME, getSearchBoxStyles } from '../../config';
|
||||||
|
import type { CompanyHeaderProps, StockSearchResult } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票信息展示组件
|
||||||
|
*/
|
||||||
|
const StockInfoDisplay = memo<{
|
||||||
|
stockCode: string;
|
||||||
|
stockName?: string;
|
||||||
|
price?: number | null;
|
||||||
|
change?: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
}>(({ stockCode, stockName, price, change, loading }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<VStack align="start" spacing={1}>
|
||||||
|
<Skeleton height="32px" width="200px" />
|
||||||
|
<Skeleton height="24px" width="150px" />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="start" spacing={0}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text
|
||||||
|
fontSize="2xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={THEME.gold}
|
||||||
|
letterSpacing="wider"
|
||||||
|
>
|
||||||
|
{stockCode}
|
||||||
|
</Text>
|
||||||
|
{stockName && (
|
||||||
|
<Text fontSize="xl" fontWeight="medium" color={THEME.textPrimary}>
|
||||||
|
{stockName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{price !== null && price !== undefined && (
|
||||||
|
<HStack spacing={3} mt={1}>
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color={THEME.textPrimary}>
|
||||||
|
¥{price.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
{change !== null && change !== undefined && (
|
||||||
|
<Badge
|
||||||
|
px={2}
|
||||||
|
py={0.5}
|
||||||
|
borderRadius="md"
|
||||||
|
bg={change >= 0 ? THEME.positiveBg : THEME.negativeBg}
|
||||||
|
color={change >= 0 ? THEME.positive : THEME.negative}
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{change >= 0 ? '+' : ''}{change.toFixed(2)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
StockInfoDisplay.displayName = 'StockInfoDisplay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索操作区组件
|
||||||
|
*/
|
||||||
|
const SearchActions = memo<{
|
||||||
|
inputCode: string;
|
||||||
|
onInputChange: (value: string) => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
isInWatchlist: boolean;
|
||||||
|
watchlistLoading: boolean;
|
||||||
|
onWatchlistToggle: () => void;
|
||||||
|
}>(({
|
||||||
|
inputCode,
|
||||||
|
onInputChange,
|
||||||
|
onSearch,
|
||||||
|
onSelect,
|
||||||
|
isInWatchlist,
|
||||||
|
watchlistLoading,
|
||||||
|
onWatchlistToggle,
|
||||||
|
}) => {
|
||||||
|
// 股票搜索 Hook
|
||||||
|
const {
|
||||||
|
searchResults,
|
||||||
|
isSearching,
|
||||||
|
handleSearch: doSearch,
|
||||||
|
clearSearch,
|
||||||
|
} = useStockSearch({
|
||||||
|
limit: 10,
|
||||||
|
debounceMs: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为 AutoComplete options
|
||||||
|
const stockOptions = useMemo(() => {
|
||||||
|
return searchResults.map((stock: StockSearchResult) => ({
|
||||||
|
value: stock.stock_code,
|
||||||
|
label: (
|
||||||
|
<Flex justify="space-between" align="center" py={1}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text fontWeight="bold" color={THEME.gold}>{stock.stock_code}</Text>
|
||||||
|
<Text color={THEME.textPrimary}>{stock.stock_name}</Text>
|
||||||
|
</HStack>
|
||||||
|
{stock.pinyin_abbr && (
|
||||||
|
<Text fontSize="xs" color={THEME.textMuted}>
|
||||||
|
{stock.pinyin_abbr.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [searchResults]);
|
||||||
|
|
||||||
|
// 选中股票
|
||||||
|
const handleSelect = useCallback((value: string) => {
|
||||||
|
clearSearch();
|
||||||
|
onSelect(value);
|
||||||
|
}, [clearSearch, onSelect]);
|
||||||
|
|
||||||
|
// 键盘事件
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onSearch();
|
||||||
|
}
|
||||||
|
}, [onSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack spacing={3}>
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<Box sx={getSearchBoxStyles(THEME)}>
|
||||||
|
<AutoComplete
|
||||||
|
value={inputCode}
|
||||||
|
options={stockOptions}
|
||||||
|
onSearch={doSearch}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder="输入代码、名称或拼音"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 搜索按钮 */}
|
||||||
|
<Button
|
||||||
|
bg={THEME.gold}
|
||||||
|
color={THEME.bg}
|
||||||
|
_hover={{ bg: THEME.goldLight }}
|
||||||
|
_active={{ bg: THEME.goldDark }}
|
||||||
|
size="md"
|
||||||
|
onClick={onSearch}
|
||||||
|
leftIcon={<Icon as={Search} boxSize={4} />}
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 自选按钮 */}
|
||||||
|
<Button
|
||||||
|
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||||
|
bg={isInWatchlist ? THEME.gold : 'transparent'}
|
||||||
|
color={isInWatchlist ? THEME.bg : THEME.gold}
|
||||||
|
borderColor={THEME.gold}
|
||||||
|
_hover={{
|
||||||
|
bg: isInWatchlist ? THEME.goldLight : 'rgba(212, 175, 55, 0.1)',
|
||||||
|
}}
|
||||||
|
size="md"
|
||||||
|
onClick={onWatchlistToggle}
|
||||||
|
isLoading={watchlistLoading}
|
||||||
|
leftIcon={
|
||||||
|
<Icon
|
||||||
|
as={Star}
|
||||||
|
boxSize={4}
|
||||||
|
fill={isInWatchlist ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{isInWatchlist ? '已自选' : '自选'}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SearchActions.displayName = 'SearchActions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company 页面顶部组件
|
||||||
|
*/
|
||||||
|
const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
||||||
|
stockCode,
|
||||||
|
stockInfo,
|
||||||
|
stockInfoLoading,
|
||||||
|
isInWatchlist,
|
||||||
|
watchlistLoading,
|
||||||
|
onStockChange,
|
||||||
|
onWatchlistToggle,
|
||||||
|
}) => {
|
||||||
|
const [inputCode, setInputCode] = useState(stockCode);
|
||||||
|
|
||||||
|
// 处理搜索
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
if (inputCode && inputCode !== stockCode) {
|
||||||
|
onStockChange(inputCode);
|
||||||
|
}
|
||||||
|
}, [inputCode, stockCode, onStockChange]);
|
||||||
|
|
||||||
|
// 处理选中
|
||||||
|
const handleSelect = useCallback((value: string) => {
|
||||||
|
setInputCode(value);
|
||||||
|
if (value !== stockCode) {
|
||||||
|
onStockChange(value);
|
||||||
|
}
|
||||||
|
}, [stockCode, onStockChange]);
|
||||||
|
|
||||||
|
// 同步 stockCode 变化
|
||||||
|
React.useEffect(() => {
|
||||||
|
setInputCode(stockCode);
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={THEME.cardBg}
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor={THEME.border}
|
||||||
|
px={6}
|
||||||
|
py={4}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
maxW="container.xl"
|
||||||
|
mx="auto"
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
wrap="wrap"
|
||||||
|
gap={4}
|
||||||
|
>
|
||||||
|
{/* 左侧:股票信息 */}
|
||||||
|
<StockInfoDisplay
|
||||||
|
stockCode={stockCode}
|
||||||
|
stockName={stockInfo?.stock_name}
|
||||||
|
price={stockInfo?.close_price}
|
||||||
|
change={stockInfo?.change_pct}
|
||||||
|
loading={stockInfoLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右侧:搜索和操作 */}
|
||||||
|
<SearchActions
|
||||||
|
inputCode={inputCode}
|
||||||
|
onInputChange={setInputCode}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
isInWatchlist={isInWatchlist}
|
||||||
|
watchlistLoading={watchlistLoading}
|
||||||
|
onWatchlistToggle={onWatchlistToggle}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CompanyHeader.displayName = 'CompanyHeader';
|
||||||
|
|
||||||
|
export default CompanyHeader;
|
||||||
115
src/views/Company/config.ts
Normal file
115
src/views/Company/config.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Company 页面配置
|
||||||
|
* - 黑金主题配置
|
||||||
|
* - Tab 配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy } from 'react';
|
||||||
|
import { Building2, TrendingUp, Wallet, FileBarChart } from 'lucide-react';
|
||||||
|
import type { CompanyTheme, TabConfig } from './types';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 黑金主题配置
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const THEME: CompanyTheme = {
|
||||||
|
// 背景色
|
||||||
|
bg: '#1A1A2E',
|
||||||
|
cardBg: '#16213E',
|
||||||
|
|
||||||
|
// 金色系
|
||||||
|
gold: '#D4AF37',
|
||||||
|
goldLight: '#F0D78C',
|
||||||
|
goldDark: '#B8960C',
|
||||||
|
|
||||||
|
// 文字色
|
||||||
|
textPrimary: '#FFFFFF',
|
||||||
|
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
textMuted: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
|
||||||
|
// 边框色
|
||||||
|
border: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
borderHover: 'rgba(212, 175, 55, 0.4)',
|
||||||
|
|
||||||
|
// 涨跌色
|
||||||
|
positive: '#EF4444',
|
||||||
|
negative: '#22C55E',
|
||||||
|
positiveBg: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
negativeBg: 'rgba(34, 197, 94, 0.2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tab 懒加载组件
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const CompanyOverview = lazy(() => import('./components/CompanyOverview'));
|
||||||
|
const MarketDataView = lazy(() => import('./MarketDataView'));
|
||||||
|
const FinancialPanorama = lazy(() => import('./components/FinancialPanorama'));
|
||||||
|
const ForecastReport = lazy(() => import('./ForecastReport'));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tab 配置
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const TAB_CONFIG: TabConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'overview',
|
||||||
|
name: '公司概览',
|
||||||
|
icon: Building2,
|
||||||
|
component: CompanyOverview,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'market',
|
||||||
|
name: '股票行情',
|
||||||
|
icon: TrendingUp,
|
||||||
|
component: MarketDataView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'financial',
|
||||||
|
name: '财务全景',
|
||||||
|
icon: Wallet,
|
||||||
|
component: FinancialPanorama,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'forecast',
|
||||||
|
name: '盈利预测',
|
||||||
|
icon: FileBarChart,
|
||||||
|
component: ForecastReport,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 搜索框样式配置(Ant Design AutoComplete)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const getSearchBoxStyles = (theme: CompanyTheme) => ({
|
||||||
|
'.ant-select-selector': {
|
||||||
|
backgroundColor: `${theme.bg} !important`,
|
||||||
|
borderColor: `${theme.border} !important`,
|
||||||
|
borderRadius: '8px !important',
|
||||||
|
height: '40px !important',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: `${theme.borderHover} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'.ant-select-selection-search-input': {
|
||||||
|
color: `${theme.textPrimary} !important`,
|
||||||
|
height: '38px !important',
|
||||||
|
},
|
||||||
|
'.ant-select-selection-placeholder': {
|
||||||
|
color: `${theme.textMuted} !important`,
|
||||||
|
},
|
||||||
|
'.ant-select-dropdown': {
|
||||||
|
backgroundColor: `${theme.cardBg} !important`,
|
||||||
|
borderColor: `${theme.border} !important`,
|
||||||
|
},
|
||||||
|
'.ant-select-item': {
|
||||||
|
color: `${theme.textPrimary} !important`,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: `${theme.bg} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'.ant-select-item-option-selected': {
|
||||||
|
backgroundColor: 'rgba(212, 175, 55, 0.2) !important',
|
||||||
|
},
|
||||||
|
});
|
||||||
173
src/views/Company/hooks/useCompanyData.ts
Normal file
173
src/views/Company/hooks/useCompanyData.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Company 页面数据加载 Hook
|
||||||
|
* - 使用 axios 请求
|
||||||
|
* - 懒加载策略
|
||||||
|
* - 自动取消请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import axios from '@utils/axiosConfig';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
|
import type {
|
||||||
|
StockInfo,
|
||||||
|
WatchlistItem,
|
||||||
|
UseCompanyDataReturn,
|
||||||
|
ApiResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
interface UseCompanyDataOptions {
|
||||||
|
stockCode: string;
|
||||||
|
/** 是否自动加载股票信息 */
|
||||||
|
autoLoadStockInfo?: boolean;
|
||||||
|
/** 是否自动加载自选股状态 */
|
||||||
|
autoLoadWatchlist?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company 页面数据管理 Hook
|
||||||
|
*/
|
||||||
|
export const useCompanyData = ({
|
||||||
|
stockCode,
|
||||||
|
autoLoadStockInfo = true,
|
||||||
|
autoLoadWatchlist = true,
|
||||||
|
}: UseCompanyDataOptions): UseCompanyDataReturn => {
|
||||||
|
// 状态
|
||||||
|
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
|
||||||
|
const [stockInfoLoading, setStockInfoLoading] = useState(false);
|
||||||
|
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||||
|
const [watchlistLoading, setWatchlistLoading] = useState(false);
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
const toast = useToast();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// AbortController 用于取消请求
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载股票基本信息
|
||||||
|
*/
|
||||||
|
const loadStockInfo = useCallback(async () => {
|
||||||
|
if (!stockCode || stockCode.length !== 6) return;
|
||||||
|
|
||||||
|
// 取消之前的请求
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setStockInfoLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<ApiResponse<StockInfo>>(
|
||||||
|
`/api/financial/stock-info/${stockCode}`,
|
||||||
|
{ signal: abortControllerRef.current.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.success && data.data) {
|
||||||
|
setStockInfo(data.data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'CanceledError') return;
|
||||||
|
logger.error('useCompanyData', 'loadStockInfo', error, { stockCode });
|
||||||
|
} finally {
|
||||||
|
setStockInfoLoading(false);
|
||||||
|
}
|
||||||
|
}, [stockCode]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载自选股状态
|
||||||
|
*/
|
||||||
|
const loadWatchlistStatus = useCallback(async () => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setIsInWatchlist(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<ApiResponse<WatchlistItem[]>>(
|
||||||
|
'/api/account/watchlist'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
const codes = new Set(data.data.map((item) => item.stock_code));
|
||||||
|
setIsInWatchlist(codes.has(stockCode));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('useCompanyData', 'loadWatchlistStatus', error);
|
||||||
|
setIsInWatchlist(false);
|
||||||
|
}
|
||||||
|
}, [stockCode, isAuthenticated]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换自选股状态
|
||||||
|
*/
|
||||||
|
const toggleWatchlist = useCallback(async () => {
|
||||||
|
if (!stockCode) {
|
||||||
|
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWatchlistLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isInWatchlist) {
|
||||||
|
// 移除自选
|
||||||
|
await axios.delete(`/api/account/watchlist/${stockCode}`);
|
||||||
|
setIsInWatchlist(false);
|
||||||
|
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
|
||||||
|
} else {
|
||||||
|
// 添加自选
|
||||||
|
await axios.post('/api/account/watchlist', { stock_code: stockCode });
|
||||||
|
setIsInWatchlist(true);
|
||||||
|
toast({ title: '已加入自选', status: 'success', duration: 1500 });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('useCompanyData', 'toggleWatchlist', error, { stockCode });
|
||||||
|
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
||||||
|
} finally {
|
||||||
|
setWatchlistLoading(false);
|
||||||
|
}
|
||||||
|
}, [stockCode, isAuthenticated, isInWatchlist, toast]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新股票信息
|
||||||
|
*/
|
||||||
|
const refreshStockInfo = useCallback(async () => {
|
||||||
|
await loadStockInfo();
|
||||||
|
}, [loadStockInfo]);
|
||||||
|
|
||||||
|
// 自动加载股票信息
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoLoadStockInfo) {
|
||||||
|
loadStockInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [autoLoadStockInfo, loadStockInfo]);
|
||||||
|
|
||||||
|
// 自动加载自选股状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoLoadWatchlist) {
|
||||||
|
loadWatchlistStatus();
|
||||||
|
}
|
||||||
|
}, [autoLoadWatchlist, loadWatchlistStatus]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stockInfo,
|
||||||
|
stockInfoLoading,
|
||||||
|
isInWatchlist,
|
||||||
|
watchlistLoading,
|
||||||
|
toggleWatchlist,
|
||||||
|
refreshStockInfo,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCompanyData;
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { AutoComplete, Spin } from 'antd';
|
|
||||||
import { useStockSearch } from '@hooks/useStockSearch';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Flex,
|
|
||||||
HStack,
|
|
||||||
VStack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Icon,
|
|
||||||
Badge,
|
|
||||||
useToast,
|
|
||||||
Skeleton,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { Search, Star, Building2, TrendingUp, Wallet, FileBarChart } from 'lucide-react';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import { getApiBase } from '../../utils/apiConfig';
|
|
||||||
|
|
||||||
// Tab 内容组件
|
|
||||||
import FinancialPanorama from './components/FinancialPanorama';
|
|
||||||
import ForecastReport from './ForecastReport';
|
|
||||||
import MarketDataView from './MarketDataView';
|
|
||||||
import CompanyOverview from './components/CompanyOverview';
|
|
||||||
|
|
||||||
// PostHog 追踪
|
|
||||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
|
||||||
|
|
||||||
// 通用组件
|
|
||||||
import SubTabContainer from '@components/SubTabContainer';
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 黑金主题配置
|
|
||||||
// ============================================
|
|
||||||
const THEME = {
|
|
||||||
bg: '#1A1A2E', // 深蓝黑背景
|
|
||||||
cardBg: '#16213E', // 卡片背景
|
|
||||||
gold: '#D4AF37', // 金色
|
|
||||||
goldLight: '#F0D78C', // 浅金色
|
|
||||||
goldDark: '#B8960C', // 深金色
|
|
||||||
textPrimary: '#FFFFFF',
|
|
||||||
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
|
||||||
textMuted: 'rgba(255, 255, 255, 0.5)',
|
|
||||||
border: 'rgba(212, 175, 55, 0.2)',
|
|
||||||
borderHover: 'rgba(212, 175, 55, 0.4)',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Tab 配置
|
|
||||||
// ============================================
|
|
||||||
const TAB_CONFIG = [
|
|
||||||
{ key: 'overview', name: '公司概览', icon: Building2, component: CompanyOverview },
|
|
||||||
{ key: 'market', name: '股票行情', icon: TrendingUp, component: MarketDataView },
|
|
||||||
{ key: 'financial', name: '财务全景', icon: Wallet, component: FinancialPanorama },
|
|
||||||
{ key: 'forecast', name: '盈利预测', icon: FileBarChart, component: ForecastReport },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 公司详情页面
|
|
||||||
*
|
|
||||||
* 使用黑金主题,紧凑布局
|
|
||||||
*/
|
|
||||||
const CompanyIndex = () => {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
|
|
||||||
const [inputCode, setInputCode] = useState(stockCode);
|
|
||||||
const [stockName, setStockName] = useState('');
|
|
||||||
const [stockPrice, setStockPrice] = useState(null);
|
|
||||||
const [priceChange, setPriceChange] = useState(null);
|
|
||||||
const prevStockCodeRef = useRef(stockCode);
|
|
||||||
const toast = useToast();
|
|
||||||
const { isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
// PostHog 事件追踪
|
|
||||||
const {
|
|
||||||
trackStockSearched,
|
|
||||||
trackTabChanged,
|
|
||||||
trackWatchlistAdded,
|
|
||||||
trackWatchlistRemoved,
|
|
||||||
} = useCompanyEvents({ stockCode });
|
|
||||||
|
|
||||||
// 股票搜索 Hook
|
|
||||||
const {
|
|
||||||
searchResults,
|
|
||||||
isSearching,
|
|
||||||
handleSearch: doSearch,
|
|
||||||
clearSearch,
|
|
||||||
} = useStockSearch({
|
|
||||||
limit: 10,
|
|
||||||
debounceMs: 300,
|
|
||||||
onSearch: (query, _count) => trackStockSearched(query, stockCode),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 转换为 AutoComplete options
|
|
||||||
const stockOptions = useMemo(() => {
|
|
||||||
return searchResults.map((stock) => ({
|
|
||||||
value: stock.stock_code,
|
|
||||||
label: (
|
|
||||||
<Flex justify="space-between" align="center" py={1}>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Text fontWeight="bold" color={THEME.gold}>{stock.stock_code}</Text>
|
|
||||||
<Text color={THEME.textPrimary}>{stock.stock_name}</Text>
|
|
||||||
</HStack>
|
|
||||||
{stock.pinyin_abbr && (
|
|
||||||
<Text fontSize="xs" color={THEME.textMuted}>
|
|
||||||
{stock.pinyin_abbr.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}, [searchResults]);
|
|
||||||
|
|
||||||
// 自选股状态
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// 加载股票基本信息
|
|
||||||
const loadStockInfo = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const base = getApiBase();
|
|
||||||
const resp = await fetch(base + `/api/financial/stock-info/${stockCode}`, {
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success && data.data) {
|
|
||||||
setStockName(data.data.stock_name || '');
|
|
||||||
setStockPrice(data.data.close_price);
|
|
||||||
setPriceChange(data.data.change_pct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('CompanyIndex', 'loadStockInfo', e);
|
|
||||||
}
|
|
||||||
}, [stockCode]);
|
|
||||||
|
|
||||||
// URL 参数变化时更新股票代码
|
|
||||||
useEffect(() => {
|
|
||||||
if (stockCode !== prevStockCodeRef.current) {
|
|
||||||
trackStockSearched(stockCode, prevStockCodeRef.current);
|
|
||||||
prevStockCodeRef.current = stockCode;
|
|
||||||
}
|
|
||||||
}, [searchParams, stockCode, trackStockSearched]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadWatchlistStatus();
|
|
||||||
loadStockInfo();
|
|
||||||
}, [loadWatchlistStatus, loadStockInfo]);
|
|
||||||
|
|
||||||
// 搜索处理
|
|
||||||
const handleSearch = () => {
|
|
||||||
if (inputCode && inputCode !== stockCode) {
|
|
||||||
trackStockSearched(inputCode, stockCode);
|
|
||||||
setStockCode(inputCode);
|
|
||||||
setSearchParams({ scode: inputCode });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 选中股票
|
|
||||||
const handleStockSelect = (value) => {
|
|
||||||
setInputCode(value);
|
|
||||||
clearSearch();
|
|
||||||
if (value !== stockCode) {
|
|
||||||
trackStockSearched(value, stockCode);
|
|
||||||
setStockCode(value);
|
|
||||||
setSearchParams({ scode: value });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自选股切换
|
|
||||||
const handleWatchlistToggle = async () => {
|
|
||||||
if (!stockCode) {
|
|
||||||
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setIsWatchlistLoading(true);
|
|
||||||
const base = getApiBase();
|
|
||||||
if (isInWatchlist) {
|
|
||||||
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('删除失败');
|
|
||||||
trackWatchlistRemoved(stockCode);
|
|
||||||
setIsInWatchlist(false);
|
|
||||||
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
|
|
||||||
} else {
|
|
||||||
const resp = await fetch(base + '/api/account/watchlist', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ stock_code: stockCode })
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('添加失败');
|
|
||||||
trackWatchlistAdded(stockCode);
|
|
||||||
setIsInWatchlist(true);
|
|
||||||
toast({ title: '已加入自选', status: 'success', duration: 1500 });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('CompanyIndex', 'handleWatchlistToggle', error);
|
|
||||||
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
|
||||||
} finally {
|
|
||||||
setIsWatchlistLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Tab 变更处理
|
|
||||||
const handleTabChange = useCallback((index, tabKey) => {
|
|
||||||
const tabNames = TAB_CONFIG.map(t => t.name);
|
|
||||||
trackTabChanged(index, tabNames[index], index);
|
|
||||||
}, [trackTabChanged]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box bg={THEME.bg} minH="calc(100vh - 60px)">
|
|
||||||
{/* 顶部搜索栏 */}
|
|
||||||
<Box
|
|
||||||
bg={THEME.cardBg}
|
|
||||||
borderBottom="1px solid"
|
|
||||||
borderColor={THEME.border}
|
|
||||||
px={6}
|
|
||||||
py={4}
|
|
||||||
>
|
|
||||||
<Flex
|
|
||||||
maxW="container.xl"
|
|
||||||
mx="auto"
|
|
||||||
justify="space-between"
|
|
||||||
align="center"
|
|
||||||
wrap="wrap"
|
|
||||||
gap={4}
|
|
||||||
>
|
|
||||||
{/* 左侧:股票信息 */}
|
|
||||||
<HStack spacing={4}>
|
|
||||||
{/* 股票代码和名称 */}
|
|
||||||
<VStack align="start" spacing={0}>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Text
|
|
||||||
fontSize="2xl"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={THEME.gold}
|
|
||||||
letterSpacing="wider"
|
|
||||||
>
|
|
||||||
{stockCode}
|
|
||||||
</Text>
|
|
||||||
{stockName && (
|
|
||||||
<Text fontSize="xl" fontWeight="medium" color={THEME.textPrimary}>
|
|
||||||
{stockName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
{stockPrice !== null && (
|
|
||||||
<HStack spacing={3} mt={1}>
|
|
||||||
<Text fontSize="lg" fontWeight="bold" color={THEME.textPrimary}>
|
|
||||||
¥{stockPrice?.toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
{priceChange !== null && (
|
|
||||||
<Badge
|
|
||||||
px={2}
|
|
||||||
py={0.5}
|
|
||||||
borderRadius="md"
|
|
||||||
bg={priceChange >= 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(34, 197, 94, 0.2)'}
|
|
||||||
color={priceChange >= 0 ? '#EF4444' : '#22C55E'}
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{priceChange >= 0 ? '+' : ''}{priceChange?.toFixed(2)}%
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 右侧:搜索和操作 */}
|
|
||||||
<HStack spacing={3}>
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
'.ant-select-selector': {
|
|
||||||
backgroundColor: `${THEME.bg} !important`,
|
|
||||||
borderColor: `${THEME.border} !important`,
|
|
||||||
borderRadius: '8px !important',
|
|
||||||
height: '40px !important',
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: `${THEME.borderHover} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.ant-select-selection-search-input': {
|
|
||||||
color: `${THEME.textPrimary} !important`,
|
|
||||||
height: '38px !important',
|
|
||||||
},
|
|
||||||
'.ant-select-selection-placeholder': {
|
|
||||||
color: `${THEME.textMuted} !important`,
|
|
||||||
},
|
|
||||||
'.ant-select-dropdown': {
|
|
||||||
backgroundColor: `${THEME.cardBg} !important`,
|
|
||||||
borderColor: `${THEME.border} !important`,
|
|
||||||
},
|
|
||||||
'.ant-select-item': {
|
|
||||||
color: `${THEME.textPrimary} !important`,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: `${THEME.bg} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.ant-select-item-option-selected': {
|
|
||||||
backgroundColor: `rgba(212, 175, 55, 0.2) !important`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AutoComplete
|
|
||||||
value={inputCode}
|
|
||||||
options={stockOptions}
|
|
||||||
onSearch={doSearch}
|
|
||||||
onSelect={handleStockSelect}
|
|
||||||
onChange={(value) => setInputCode(value)}
|
|
||||||
placeholder="输入代码、名称或拼音"
|
|
||||||
style={{ width: 220 }}
|
|
||||||
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSearch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 搜索按钮 */}
|
|
||||||
<Button
|
|
||||||
bg={THEME.gold}
|
|
||||||
color={THEME.bg}
|
|
||||||
_hover={{ bg: THEME.goldLight }}
|
|
||||||
_active={{ bg: THEME.goldDark }}
|
|
||||||
size="md"
|
|
||||||
onClick={handleSearch}
|
|
||||||
leftIcon={<Icon as={Search} boxSize={4} />}
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 自选按钮 */}
|
|
||||||
<Button
|
|
||||||
variant={isInWatchlist ? 'solid' : 'outline'}
|
|
||||||
bg={isInWatchlist ? THEME.gold : 'transparent'}
|
|
||||||
color={isInWatchlist ? THEME.bg : THEME.gold}
|
|
||||||
borderColor={THEME.gold}
|
|
||||||
_hover={{
|
|
||||||
bg: isInWatchlist ? THEME.goldLight : 'rgba(212, 175, 55, 0.1)',
|
|
||||||
}}
|
|
||||||
size="md"
|
|
||||||
onClick={handleWatchlistToggle}
|
|
||||||
isLoading={isWatchlistLoading}
|
|
||||||
leftIcon={<Icon as={Star} boxSize={4} fill={isInWatchlist ? 'currentColor' : 'none'} />}
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{isInWatchlist ? '已自选' : '自选'}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 主内容区 - Tab 切换 */}
|
|
||||||
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
|
||||||
<Box
|
|
||||||
bg={THEME.cardBg}
|
|
||||||
borderRadius="xl"
|
|
||||||
border="1px solid"
|
|
||||||
borderColor={THEME.border}
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
<SubTabContainer
|
|
||||||
tabs={TAB_CONFIG}
|
|
||||||
componentProps={{ stockCode }}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
themePreset="blackGold"
|
|
||||||
contentPadding={6}
|
|
||||||
isLazy={true}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CompanyIndex;
|
|
||||||
150
src/views/Company/index.tsx
Normal file
150
src/views/Company/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* 公司详情页面
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 黑金主题设计
|
||||||
|
* - 懒加载 Tab 内容
|
||||||
|
* - memo 性能优化
|
||||||
|
* - axios 数据请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo, useCallback, useRef, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Box, Spinner, Center } from '@chakra-ui/react';
|
||||||
|
import SubTabContainer from '@components/SubTabContainer';
|
||||||
|
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||||
|
import { useCompanyData } from './hooks/useCompanyData';
|
||||||
|
import CompanyHeader from './components/CompanyHeader';
|
||||||
|
import { THEME, TAB_CONFIG } from './config';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 加载状态组件
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const TabLoadingFallback = memo(() => (
|
||||||
|
<Center py={20}>
|
||||||
|
<Spinner size="xl" color={THEME.gold} thickness="3px" />
|
||||||
|
</Center>
|
||||||
|
));
|
||||||
|
|
||||||
|
TabLoadingFallback.displayName = 'TabLoadingFallback';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 主内容区组件
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface CompanyContentProps {
|
||||||
|
stockCode: string;
|
||||||
|
onTabChange: (index: number, tabKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompanyContent = memo<CompanyContentProps>(({ stockCode, onTabChange }) => (
|
||||||
|
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
||||||
|
<Box
|
||||||
|
bg={THEME.cardBg}
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={THEME.border}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Suspense fallback={<TabLoadingFallback />}>
|
||||||
|
<SubTabContainer
|
||||||
|
tabs={TAB_CONFIG}
|
||||||
|
componentProps={{ stockCode }}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
themePreset="blackGold"
|
||||||
|
contentPadding={6}
|
||||||
|
isLazy={true}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
CompanyContent.displayName = 'CompanyContent';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 主页面组件
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const CompanyIndex: React.FC = () => {
|
||||||
|
// URL 参数管理
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const stockCode = searchParams.get('scode') || '000001';
|
||||||
|
const prevStockCodeRef = useRef(stockCode);
|
||||||
|
|
||||||
|
// 数据加载 Hook
|
||||||
|
const {
|
||||||
|
stockInfo,
|
||||||
|
stockInfoLoading,
|
||||||
|
isInWatchlist,
|
||||||
|
watchlistLoading,
|
||||||
|
toggleWatchlist,
|
||||||
|
} = useCompanyData({ stockCode });
|
||||||
|
|
||||||
|
// 事件追踪 Hook
|
||||||
|
const {
|
||||||
|
trackStockSearched,
|
||||||
|
trackTabChanged,
|
||||||
|
trackWatchlistAdded,
|
||||||
|
trackWatchlistRemoved,
|
||||||
|
} = useCompanyEvents({ stockCode });
|
||||||
|
|
||||||
|
// 股票代码变化追踪
|
||||||
|
useEffect(() => {
|
||||||
|
if (stockCode !== prevStockCodeRef.current) {
|
||||||
|
trackStockSearched(stockCode, prevStockCodeRef.current);
|
||||||
|
prevStockCodeRef.current = stockCode;
|
||||||
|
}
|
||||||
|
}, [stockCode, trackStockSearched]);
|
||||||
|
|
||||||
|
// 处理股票切换
|
||||||
|
const handleStockChange = useCallback((newCode: string) => {
|
||||||
|
if (newCode && newCode !== stockCode) {
|
||||||
|
trackStockSearched(newCode, stockCode);
|
||||||
|
setSearchParams({ scode: newCode });
|
||||||
|
}
|
||||||
|
}, [stockCode, setSearchParams, trackStockSearched]);
|
||||||
|
|
||||||
|
// 处理自选股切换(带追踪)
|
||||||
|
const handleWatchlistToggle = useCallback(async () => {
|
||||||
|
const wasInWatchlist = isInWatchlist;
|
||||||
|
await toggleWatchlist();
|
||||||
|
|
||||||
|
// 追踪事件(根据操作前的状态判断)
|
||||||
|
if (wasInWatchlist) {
|
||||||
|
trackWatchlistRemoved(stockCode);
|
||||||
|
} else {
|
||||||
|
trackWatchlistAdded(stockCode);
|
||||||
|
}
|
||||||
|
}, [stockCode, isInWatchlist, toggleWatchlist, trackWatchlistAdded, trackWatchlistRemoved]);
|
||||||
|
|
||||||
|
// 处理 Tab 切换
|
||||||
|
const handleTabChange = useCallback((index: number, tabKey: string) => {
|
||||||
|
const tabName = TAB_CONFIG[index]?.name || tabKey;
|
||||||
|
trackTabChanged(index, tabName, index);
|
||||||
|
}, [trackTabChanged]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg={THEME.bg} minH="calc(100vh - 60px)">
|
||||||
|
{/* 顶部搜索栏 */}
|
||||||
|
<CompanyHeader
|
||||||
|
stockCode={stockCode}
|
||||||
|
stockInfo={stockInfo}
|
||||||
|
stockInfoLoading={stockInfoLoading}
|
||||||
|
isInWatchlist={isInWatchlist}
|
||||||
|
watchlistLoading={watchlistLoading}
|
||||||
|
onStockChange={handleStockChange}
|
||||||
|
onWatchlistToggle={handleWatchlistToggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主内容区 */}
|
||||||
|
<CompanyContent
|
||||||
|
stockCode={stockCode}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(CompanyIndex);
|
||||||
124
src/views/Company/types.ts
Normal file
124
src/views/Company/types.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Company 页面类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { IconType } from 'react-icons';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 主题类型
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface CompanyTheme {
|
||||||
|
bg: string;
|
||||||
|
cardBg: string;
|
||||||
|
gold: string;
|
||||||
|
goldLight: string;
|
||||||
|
goldDark: string;
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
textMuted: string;
|
||||||
|
border: string;
|
||||||
|
borderHover: string;
|
||||||
|
positive: string;
|
||||||
|
negative: string;
|
||||||
|
positiveBg: string;
|
||||||
|
negativeBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tab 配置类型
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface TabConfig {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
icon: LucideIcon | IconType | ComponentType;
|
||||||
|
component: ComponentType<TabComponentProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabComponentProps {
|
||||||
|
stockCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 股票数据类型
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface StockInfo {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
close_price?: number;
|
||||||
|
change_pct?: number;
|
||||||
|
market?: string;
|
||||||
|
industry?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockSearchResult {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
pinyin_abbr?: string;
|
||||||
|
market?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 自选股类型
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface WatchlistItem {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name?: string;
|
||||||
|
added_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchlistResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: WatchlistItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API 响应类型
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Hook 返回类型
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface UseCompanyDataReturn {
|
||||||
|
// 股票信息
|
||||||
|
stockInfo: StockInfo | null;
|
||||||
|
stockInfoLoading: boolean;
|
||||||
|
|
||||||
|
// 自选股状态
|
||||||
|
isInWatchlist: boolean;
|
||||||
|
watchlistLoading: boolean;
|
||||||
|
toggleWatchlist: () => Promise<void>;
|
||||||
|
|
||||||
|
// 刷新方法
|
||||||
|
refreshStockInfo: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 组件 Props 类型
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface CompanyHeaderProps {
|
||||||
|
stockCode: string;
|
||||||
|
stockInfo: StockInfo | null;
|
||||||
|
stockInfoLoading: boolean;
|
||||||
|
isInWatchlist: boolean;
|
||||||
|
watchlistLoading: boolean;
|
||||||
|
onStockChange: (code: string) => void;
|
||||||
|
onWatchlistToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyPageProps {
|
||||||
|
// 未来可扩展
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user