feat: 添加Company 页面头部组件 CompanyHeader
index.js # 组合导出 SearchBar.js # 股票搜索栏 WatchlistButton.js # 自选股按钮
This commit is contained in:
59
src/views/Company/components/CompanyHeader/SearchBar.js
Normal file
59
src/views/Company/components/CompanyHeader/SearchBar.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// src/views/Company/components/CompanyHeader/SearchBar.js
|
||||||
|
// 股票搜索栏组件
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
HStack,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { SearchIcon } from '@chakra-ui/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票搜索栏组件
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.inputCode - 输入框当前值
|
||||||
|
* @param {Function} props.onInputChange - 输入变化回调
|
||||||
|
* @param {Function} props.onSearch - 搜索按钮点击回调
|
||||||
|
* @param {Function} props.onKeyPress - 键盘事件回调
|
||||||
|
*/
|
||||||
|
const SearchBar = ({
|
||||||
|
inputCode,
|
||||||
|
onInputChange,
|
||||||
|
onSearch,
|
||||||
|
onKeyPress,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<InputGroup size="lg" maxW="300px">
|
||||||
|
<InputLeftElement pointerEvents="none">
|
||||||
|
<SearchIcon color="gray.400" />
|
||||||
|
</InputLeftElement>
|
||||||
|
<Input
|
||||||
|
placeholder="输入股票代码"
|
||||||
|
value={inputCode}
|
||||||
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
borderRadius="md"
|
||||||
|
_focus={{
|
||||||
|
borderColor: 'blue.500',
|
||||||
|
boxShadow: '0 0 0 1px #3182ce',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
<Button
|
||||||
|
colorScheme="blue"
|
||||||
|
size="lg"
|
||||||
|
onClick={onSearch}
|
||||||
|
leftIcon={<SearchIcon />}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchBar;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// src/views/Company/components/CompanyHeader/WatchlistButton.js
|
||||||
|
// 自选股按钮组件
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@chakra-ui/react';
|
||||||
|
import { StarIcon } from '@chakra-ui/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自选股按钮组件
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.isInWatchlist - 是否已在自选股中
|
||||||
|
* @param {boolean} props.isLoading - 是否正在加载
|
||||||
|
* @param {Function} props.onClick - 点击回调
|
||||||
|
*/
|
||||||
|
const WatchlistButton = ({
|
||||||
|
isInWatchlist,
|
||||||
|
isLoading,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
colorScheme={isInWatchlist ? 'yellow' : 'teal'}
|
||||||
|
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||||
|
size="lg"
|
||||||
|
onClick={onClick}
|
||||||
|
leftIcon={<StarIcon />}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
{isInWatchlist ? '已关注' : '关注'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WatchlistButton;
|
||||||
94
src/views/Company/components/CompanyHeader/index.js
Normal file
94
src/views/Company/components/CompanyHeader/index.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// src/views/Company/components/CompanyHeader/index.js
|
||||||
|
// 公司详情页面头部区域组件
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import SearchBar from './SearchBar';
|
||||||
|
import WatchlistButton from './WatchlistButton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公司详情页面头部区域组件
|
||||||
|
*
|
||||||
|
* 包含:
|
||||||
|
* - 页面标题和描述
|
||||||
|
* - 股票搜索栏
|
||||||
|
* - 自选股按钮
|
||||||
|
* - 当前股票代码显示
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.stockCode - 当前股票代码
|
||||||
|
* @param {string} props.inputCode - 搜索输入框值
|
||||||
|
* @param {Function} props.onInputChange - 输入变化回调
|
||||||
|
* @param {Function} props.onSearch - 搜索回调
|
||||||
|
* @param {Function} props.onKeyPress - 键盘事件回调
|
||||||
|
* @param {boolean} props.isInWatchlist - 是否在自选股中
|
||||||
|
* @param {boolean} props.isWatchlistLoading - 自选股操作加载中
|
||||||
|
* @param {Function} props.onWatchlistToggle - 自选股切换回调
|
||||||
|
* @param {string} props.bgColor - 背景颜色
|
||||||
|
*/
|
||||||
|
const CompanyHeader = ({
|
||||||
|
stockCode,
|
||||||
|
inputCode,
|
||||||
|
onInputChange,
|
||||||
|
onSearch,
|
||||||
|
onKeyPress,
|
||||||
|
isInWatchlist,
|
||||||
|
isWatchlistLoading,
|
||||||
|
onWatchlistToggle,
|
||||||
|
bgColor,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card bg={bgColor} shadow="md">
|
||||||
|
<CardBody>
|
||||||
|
<HStack justify="space-between" align="center">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<VStack align="start" spacing={1}>
|
||||||
|
<Heading size="lg">个股详情</Heading>
|
||||||
|
<Text color="gray.600" fontSize="sm">
|
||||||
|
查看股票实时行情、财务数据和盈利预测
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* 操作区域 */}
|
||||||
|
<HStack spacing={3}>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<SearchBar
|
||||||
|
inputCode={inputCode}
|
||||||
|
onInputChange={onInputChange}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 自选股按钮 */}
|
||||||
|
<WatchlistButton
|
||||||
|
isInWatchlist={isInWatchlist}
|
||||||
|
isLoading={isWatchlistLoading}
|
||||||
|
onClick={onWatchlistToggle}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 当前股票信息 */}
|
||||||
|
<HStack mt={4} spacing={4}>
|
||||||
|
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
|
||||||
|
股票代码: {stockCode}
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
更新时间: {new Date().toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompanyHeader;
|
||||||
166
src/views/Company/hooks/useCompanyWatchlist.js
Normal file
166
src/views/Company/hooks/useCompanyWatchlist.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// src/views/Company/hooks/useCompanyWatchlist.js
|
||||||
|
// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import {
|
||||||
|
loadWatchlist,
|
||||||
|
toggleWatchlist,
|
||||||
|
optimisticAddWatchlist,
|
||||||
|
optimisticRemoveWatchlist
|
||||||
|
} from '@store/slices/stockSlice';
|
||||||
|
import { TOAST_MESSAGES } from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company 页面自选股管理 Hook
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 检查当前股票是否在自选股中
|
||||||
|
* - 提供添加/移除自选股功能
|
||||||
|
* - 与 Redux stockSlice 同步
|
||||||
|
*
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {string} options.stockCode - 当前股票代码
|
||||||
|
* @param {Object} [options.tracking] - 追踪回调
|
||||||
|
* @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调
|
||||||
|
* @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调
|
||||||
|
* @returns {Object} 自选股状态和操作方法
|
||||||
|
*/
|
||||||
|
export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const toast = useToast();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// 从 Redux 获取自选股列表
|
||||||
|
const watchlist = useSelector((state) => state.stock.watchlist);
|
||||||
|
const watchlistLoading = useSelector((state) => state.stock.loading.watchlist);
|
||||||
|
|
||||||
|
// 追踪是否已初始化(防止无限循环)
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派生状态:判断当前股票是否在自选股中
|
||||||
|
* 使用 useMemo 避免重复计算
|
||||||
|
*/
|
||||||
|
const isInWatchlist = useMemo(() => {
|
||||||
|
if (!stockCode || !Array.isArray(watchlist)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标准化股票代码(提取6位数字)
|
||||||
|
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||||
|
const targetCode = normalize(stockCode);
|
||||||
|
|
||||||
|
return watchlist.some((item) => normalize(item.stock_code) === targetCode);
|
||||||
|
}, [watchlist, stockCode]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化:加载自选股列表
|
||||||
|
* 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) {
|
||||||
|
hasInitializedRef.current = true;
|
||||||
|
dispatch(loadWatchlist());
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, watchlistLoading, dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换自选股状态(乐观更新模式)
|
||||||
|
* 1. 立即更新 UI(无 loading)
|
||||||
|
* 2. 后台静默请求 API
|
||||||
|
* 3. 失败时回滚并提示
|
||||||
|
*/
|
||||||
|
const toggle = useCallback(async () => {
|
||||||
|
// 参数校验
|
||||||
|
if (!stockCode) {
|
||||||
|
logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode });
|
||||||
|
toast(TOAST_MESSAGES.INVALID_CODE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限校验
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode });
|
||||||
|
toast(TOAST_MESSAGES.LOGIN_REQUIRED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标准化股票代码用于匹配
|
||||||
|
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
|
||||||
|
const targetCode = normalize(stockCode);
|
||||||
|
|
||||||
|
// 从 watchlist 中找到原始 stock_code(保持与后端数据结构一致)
|
||||||
|
const matchedItem = watchlist.find(
|
||||||
|
item => normalize(item.stock_code) === targetCode
|
||||||
|
);
|
||||||
|
// 移除时使用原始 stock_code,添加时使用传入的 stockCode
|
||||||
|
const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode;
|
||||||
|
|
||||||
|
// 保存当前状态用于回滚
|
||||||
|
const wasInWatchlist = isInWatchlist;
|
||||||
|
|
||||||
|
logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', {
|
||||||
|
stockCode,
|
||||||
|
codeForApi,
|
||||||
|
wasInWatchlist,
|
||||||
|
action: wasInWatchlist ? 'remove' : 'add',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 乐观更新:立即更新 UI(不显示 loading)
|
||||||
|
if (wasInWatchlist) {
|
||||||
|
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||||
|
} else {
|
||||||
|
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. 后台静默请求 API
|
||||||
|
await dispatch(
|
||||||
|
toggleWatchlist({
|
||||||
|
stockCode: codeForApi,
|
||||||
|
stockName: matchedItem?.stock_name || '',
|
||||||
|
isInWatchlist: wasInWatchlist,
|
||||||
|
})
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// 3. 成功:触发追踪回调(不显示 toast,状态已更新)
|
||||||
|
if (wasInWatchlist) {
|
||||||
|
tracking.onRemove?.(stockCode);
|
||||||
|
} else {
|
||||||
|
tracking.onAdd?.(stockCode);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 4. 失败:回滚状态 + 显示错误提示
|
||||||
|
logger.error('useCompanyWatchlist', 'toggle', error, {
|
||||||
|
stockCode,
|
||||||
|
wasInWatchlist,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 回滚操作
|
||||||
|
if (wasInWatchlist) {
|
||||||
|
// 之前在自选中,乐观删除了,现在要恢复
|
||||||
|
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
|
||||||
|
} else {
|
||||||
|
// 之前不在自选中,乐观添加了,现在要移除
|
||||||
|
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(TOAST_MESSAGES.WATCHLIST_ERROR);
|
||||||
|
}
|
||||||
|
}, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isInWatchlist, // 是否在自选股中
|
||||||
|
isLoading: watchlistLoading, // 仅初始加载时显示 loading(乐观更新模式)
|
||||||
|
|
||||||
|
// 操作方法
|
||||||
|
toggle, // 切换自选状态
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCompanyWatchlist;
|
||||||
Reference in New Issue
Block a user