diff --git a/src/views/Company/components/CompanyHeader/SearchBar.js b/src/views/Company/components/CompanyHeader/SearchBar.js new file mode 100644 index 00000000..ed556ad1 --- /dev/null +++ b/src/views/Company/components/CompanyHeader/SearchBar.js @@ -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 ( + + + + + + onInputChange(e.target.value)} + onKeyPress={onKeyPress} + borderRadius="md" + _focus={{ + borderColor: 'blue.500', + boxShadow: '0 0 0 1px #3182ce', + }} + /> + + + + ); +}; + +export default SearchBar; diff --git a/src/views/Company/components/CompanyHeader/WatchlistButton.js b/src/views/Company/components/CompanyHeader/WatchlistButton.js new file mode 100644 index 00000000..2b788ab8 --- /dev/null +++ b/src/views/Company/components/CompanyHeader/WatchlistButton.js @@ -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 ( + + ); +}; + +export default WatchlistButton; diff --git a/src/views/Company/components/CompanyHeader/index.js b/src/views/Company/components/CompanyHeader/index.js new file mode 100644 index 00000000..e46c7e30 --- /dev/null +++ b/src/views/Company/components/CompanyHeader/index.js @@ -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 ( + + + + {/* 标题区域 */} + + 个股详情 + + 查看股票实时行情、财务数据和盈利预测 + + + + {/* 操作区域 */} + + {/* 搜索栏 */} + + + {/* 自选股按钮 */} + + + + + {/* 当前股票信息 */} + + + 股票代码: {stockCode} + + + 更新时间: {new Date().toLocaleString()} + + + + + ); +}; + +export default CompanyHeader; diff --git a/src/views/Company/hooks/useCompanyWatchlist.js b/src/views/Company/hooks/useCompanyWatchlist.js new file mode 100644 index 00000000..56019470 --- /dev/null +++ b/src/views/Company/hooks/useCompanyWatchlist.js @@ -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;