update pay ui

This commit is contained in:
2025-12-17 17:29:08 +08:00
parent 8def7f355b
commit 697c366e88
7 changed files with 850 additions and 473 deletions

View File

@@ -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;

View 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
View 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',
},
});

View 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;

View File

@@ -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
View 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
View 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 {
// 未来可扩展
}