Compare commits
13 Commits
feature_20
...
2a653afea1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a653afea1 | ||
|
|
6628ddc7b2 | ||
|
|
5dc480f5f4 | ||
|
|
99f102a213 | ||
| a37206ec97 | |||
|
|
9f6c98135f | ||
| 5e5e2160b0 | |||
|
|
f0074bca42 | ||
|
|
e8285599e8 | ||
| 0eb760fa31 | |||
| 805b897afa | |||
| 2988af9806 | |||
| 63023adcf3 |
30
app.py
30
app.py
@@ -12232,12 +12232,19 @@ def get_market_statistics():
|
||||
|
||||
available_dates = [str(row.TRADEDATE) for row in available_dates_result]
|
||||
|
||||
# 格式化日期为 YYYY-MM-DD
|
||||
formatted_trade_date = trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date).split(' ')[0][:10]
|
||||
formatted_available_dates = [
|
||||
d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d).split(' ')[0][:10]
|
||||
for d in [row.TRADEDATE for row in available_dates_result]
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'trade_date': str(trade_date),
|
||||
'trade_date': formatted_trade_date,
|
||||
'summary': summary,
|
||||
'details': list(statistics.values()),
|
||||
'available_dates': available_dates
|
||||
'available_dates': formatted_available_dates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -12277,19 +12284,30 @@ def get_daily_top_concepts():
|
||||
top_concepts = []
|
||||
|
||||
for concept in data.get('results', []):
|
||||
# 保持与 /concept-api/search 相同的字段结构
|
||||
top_concepts.append({
|
||||
'concept_id': concept.get('concept_id'),
|
||||
'concept_name': concept.get('concept'),
|
||||
'concept': concept.get('concept'), # 原始字段名
|
||||
'concept_name': concept.get('concept'), # 兼容旧字段名
|
||||
'description': concept.get('description'),
|
||||
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0),
|
||||
'stock_count': concept.get('stock_count', 0),
|
||||
'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票
|
||||
'score': concept.get('score'),
|
||||
'match_type': concept.get('match_type'),
|
||||
'price_info': concept.get('price_info', {}), # 完整的价格信息
|
||||
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段
|
||||
'happened_times': concept.get('happened_times', []), # 历史触发时间
|
||||
'stocks': concept.get('stocks', []), # 返回完整股票列表
|
||||
'hot_score': concept.get('hot_score')
|
||||
})
|
||||
|
||||
# 格式化日期为 YYYY-MM-DD
|
||||
price_date = data.get('price_date', '')
|
||||
formatted_date = str(price_date).split(' ')[0][:10] if price_date else ''
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': top_concepts,
|
||||
'trade_date': data.get('price_date'),
|
||||
'trade_date': formatted_date,
|
||||
'count': len(top_concepts)
|
||||
})
|
||||
else:
|
||||
|
||||
@@ -1999,7 +1999,7 @@ class MCPAgentIntegrated:
|
||||
model=self.kimi_model,
|
||||
messages=messages,
|
||||
temperature=1.0, # Kimi 推荐
|
||||
max_tokens=8192, # 足够容纳 reasoning_content
|
||||
max_tokens=128000, # 足够容纳 reasoning_content
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
@@ -2074,7 +2074,7 @@ class MCPAgentIntegrated:
|
||||
model=self.deepmoney_model,
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=8192,
|
||||
max_tokens=32784,
|
||||
)
|
||||
|
||||
summary = response.choices[0].message.content
|
||||
@@ -2268,7 +2268,7 @@ class MCPAgentIntegrated:
|
||||
model="kimi-k2-turbo-preview", # 使用非思考模型,更快
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=8192, # 增加 token 限制以支持图表配置
|
||||
max_tokens=128000, # 增加 token 限制以支持图表配置
|
||||
)
|
||||
|
||||
summary = response.choices[0].message.content
|
||||
@@ -2355,7 +2355,7 @@ class MCPAgentIntegrated:
|
||||
model=self.deepmoney_model,
|
||||
messages=messages,
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
max_tokens=32768,
|
||||
)
|
||||
|
||||
title = response.choices[0].message.content.strip()
|
||||
@@ -2450,7 +2450,7 @@ class MCPAgentIntegrated:
|
||||
model=planning_model,
|
||||
messages=messages,
|
||||
temperature=1.0,
|
||||
max_tokens=8192,
|
||||
max_tokens=32768,
|
||||
stream=True, # 启用流式输出
|
||||
)
|
||||
|
||||
@@ -2494,7 +2494,7 @@ class MCPAgentIntegrated:
|
||||
model=self.deepmoney_model,
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=8192,
|
||||
max_tokens=32768,
|
||||
)
|
||||
|
||||
plan_content = fallback_response.choices[0].message.content
|
||||
@@ -2690,7 +2690,7 @@ class MCPAgentIntegrated:
|
||||
model="kimi-k2-turbo-preview",
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=8192,
|
||||
max_tokens=32768,
|
||||
stream=True, # 启用流式输出
|
||||
)
|
||||
|
||||
@@ -2724,7 +2724,7 @@ class MCPAgentIntegrated:
|
||||
model=self.deepmoney_model,
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=8192,
|
||||
max_tokens=32768,
|
||||
)
|
||||
|
||||
final_summary = fallback_response.choices[0].message.content
|
||||
@@ -3676,7 +3676,7 @@ async def stream_role_response(
|
||||
tool_choice="auto",
|
||||
stream=False, # 工具调用不使用流式
|
||||
temperature=0.7,
|
||||
max_tokens=8192, # 增大 token 限制以避免输出被截断
|
||||
max_tokens=32768, # 增大 token 限制以避免输出被截断
|
||||
)
|
||||
|
||||
assistant_message = response.choices[0].message
|
||||
|
||||
@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
|
||||
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
|
||||
1
src/data/tradingDays.json
Normal file
1
src/data/tradingDays.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,16 +1,19 @@
|
||||
// src/hooks/useWatchlist.js
|
||||
// 自选股管理自定义 Hook
|
||||
// 自选股管理自定义 Hook(导航栏专用,与 Redux 状态同步)
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice';
|
||||
|
||||
const WATCHLIST_PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* 自选股管理 Hook
|
||||
* 自选股管理 Hook(导航栏专用)
|
||||
* 提供自选股加载、分页、移除等功能
|
||||
* 监听 Redux 中的 watchlist 变化,自动刷新行情数据
|
||||
*
|
||||
* @returns {{
|
||||
* watchlistQuotes: Array,
|
||||
@@ -19,14 +22,37 @@ const WATCHLIST_PAGE_SIZE = 10;
|
||||
* setWatchlistPage: Function,
|
||||
* WATCHLIST_PAGE_SIZE: number,
|
||||
* loadWatchlistQuotes: Function,
|
||||
* handleRemoveFromWatchlist: Function
|
||||
* handleRemoveFromWatchlist: Function,
|
||||
* followingEvents: Array
|
||||
* }}
|
||||
*/
|
||||
export const useWatchlist = () => {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
|
||||
const [watchlistLoading, setWatchlistLoading] = useState(false);
|
||||
const [watchlistPage, setWatchlistPage] = useState(1);
|
||||
const [followingEvents, setFollowingEvents] = useState([]);
|
||||
|
||||
// 从 Redux 获取自选股列表长度(用于监听变化)
|
||||
// 使用 length 作为依赖,避免数组引用变化导致不必要的重新渲染
|
||||
const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0);
|
||||
|
||||
// 检查 Redux watchlist 是否已初始化(加载状态)
|
||||
const reduxWatchlistLoading = useSelector(state => state.stock.loading?.watchlist);
|
||||
|
||||
// 用于跟踪上一次的 watchlist 长度
|
||||
const prevWatchlistLengthRef = useRef(-1); // 初始设为 -1,确保第一次变化也能检测到
|
||||
|
||||
// 初始化时加载 Redux watchlist(确保 Redux 状态被初始化)
|
||||
const hasInitializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
logger.debug('useWatchlist', '初始化 Redux watchlist');
|
||||
dispatch(loadWatchlist());
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// 加载自选股实时行情
|
||||
const loadWatchlistQuotes = useCallback(async () => {
|
||||
@@ -42,6 +68,7 @@ export const useWatchlist = () => {
|
||||
const data = await resp.json();
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setWatchlistQuotes(data.data);
|
||||
logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length });
|
||||
} else {
|
||||
setWatchlistQuotes([]);
|
||||
}
|
||||
@@ -58,35 +85,72 @@ export const useWatchlist = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从自选股移除
|
||||
// 监听 Redux watchlist 长度变化,自动刷新行情数据
|
||||
useEffect(() => {
|
||||
const currentLength = reduxWatchlistLength;
|
||||
const prevLength = prevWatchlistLengthRef.current;
|
||||
|
||||
// 只有当 watchlist 长度发生变化时才刷新
|
||||
// prevLength = -1 表示初始状态,此时不触发刷新(由菜单打开时触发)
|
||||
if (prevLength !== -1 && currentLength !== prevLength) {
|
||||
logger.debug('useWatchlist', 'Redux watchlist 长度变化,刷新行情', {
|
||||
prevLength,
|
||||
currentLength
|
||||
});
|
||||
|
||||
// 延迟一小段时间再刷新,确保后端数据已更新
|
||||
const timer = setTimeout(() => {
|
||||
logger.debug('useWatchlist', '执行 loadWatchlistQuotes');
|
||||
loadWatchlistQuotes();
|
||||
}, 500);
|
||||
|
||||
prevWatchlistLengthRef.current = currentLength;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
// 更新 ref
|
||||
prevWatchlistLengthRef.current = currentLength;
|
||||
}, [reduxWatchlistLength, loadWatchlistQuotes]);
|
||||
|
||||
// 从自选股移除(同时更新 Redux 和本地状态)
|
||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
// 找到股票名称
|
||||
const stockItem = watchlistQuotes.find(item => {
|
||||
const normalize6 = (code) => {
|
||||
const m = String(code || '').match(/(\d{6})/);
|
||||
return m ? m[1] : String(code || '');
|
||||
};
|
||||
return normalize6(item.stock_code) === normalize6(stockCode);
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && data && data.success !== false) {
|
||||
setWatchlistQuotes((prev) => {
|
||||
const normalize6 = (code) => {
|
||||
const m = String(code || '').match(/(\d{6})/);
|
||||
return m ? m[1] : String(code || '');
|
||||
};
|
||||
const target = normalize6(stockCode);
|
||||
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
|
||||
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
|
||||
setWatchlistPage((p) => Math.min(p, newMaxPage));
|
||||
return updated;
|
||||
});
|
||||
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
|
||||
} else {
|
||||
toast({ title: '移除失败', status: 'error', duration: 2000 });
|
||||
}
|
||||
const stockName = stockItem?.stock_name || '';
|
||||
|
||||
// 通过 Redux action 移除(会同步更新 Redux 状态)
|
||||
await dispatch(toggleWatchlistAction({
|
||||
stockCode,
|
||||
stockName,
|
||||
isInWatchlist: true // 表示当前在自选股中,需要移除
|
||||
})).unwrap();
|
||||
|
||||
// 更新本地状态(立即响应 UI)
|
||||
setWatchlistQuotes((prev) => {
|
||||
const normalize6 = (code) => {
|
||||
const m = String(code || '').match(/(\d{6})/);
|
||||
return m ? m[1] : String(code || '');
|
||||
};
|
||||
const target = normalize6(stockCode);
|
||||
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
|
||||
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
|
||||
setWatchlistPage((p) => Math.min(p, newMaxPage));
|
||||
return updated;
|
||||
});
|
||||
|
||||
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
|
||||
} catch (e) {
|
||||
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
|
||||
logger.error('useWatchlist', '移除自选股失败', e);
|
||||
toast({ title: e.message || '移除失败', status: 'error', duration: 2000 });
|
||||
}
|
||||
}, [toast]);
|
||||
}, [dispatch, watchlistQuotes, toast]);
|
||||
|
||||
return {
|
||||
watchlistQuotes,
|
||||
@@ -95,6 +159,7 @@ export const useWatchlist = () => {
|
||||
setWatchlistPage,
|
||||
WATCHLIST_PAGE_SIZE,
|
||||
loadWatchlistQuotes,
|
||||
handleRemoveFromWatchlist
|
||||
handleRemoveFromWatchlist,
|
||||
followingEvents
|
||||
};
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ export const marketHandlers = [
|
||||
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
|
||||
];
|
||||
|
||||
// 股票池
|
||||
// 股票池(扩展到足够多的股票)
|
||||
const stockPool = [
|
||||
{ stock_code: '600519', stock_name: '贵州茅台' },
|
||||
{ stock_code: '300750', stock_name: '宁德时代' },
|
||||
@@ -104,30 +104,102 @@ export const marketHandlers = [
|
||||
{ stock_code: '300274', stock_name: '阳光电源' },
|
||||
{ stock_code: '688981', stock_name: '中芯国际' },
|
||||
{ stock_code: '000725', stock_name: '京东方A' },
|
||||
{ stock_code: '600036', stock_name: '招商银行' },
|
||||
{ stock_code: '000858', stock_name: '五粮液' },
|
||||
{ stock_code: '601166', stock_name: '兴业银行' },
|
||||
{ stock_code: '600276', stock_name: '恒瑞医药' },
|
||||
{ stock_code: '000333', stock_name: '美的集团' },
|
||||
{ stock_code: '600887', stock_name: '伊利股份' },
|
||||
{ stock_code: '002415', stock_name: '海康威视' },
|
||||
{ stock_code: '601888', stock_name: '中国中免' },
|
||||
{ stock_code: '300059', stock_name: '东方财富' },
|
||||
{ stock_code: '002475', stock_name: '立讯精密' },
|
||||
{ stock_code: '600900', stock_name: '长江电力' },
|
||||
{ stock_code: '601398', stock_name: '工商银行' },
|
||||
{ stock_code: '600030', stock_name: '中信证券' },
|
||||
{ stock_code: '000568', stock_name: '泸州老窖' },
|
||||
{ stock_code: '002352', stock_name: '顺丰控股' },
|
||||
{ stock_code: '600809', stock_name: '山西汾酒' },
|
||||
{ stock_code: '300015', stock_name: '爱尔眼科' },
|
||||
{ stock_code: '002142', stock_name: '宁波银行' },
|
||||
{ stock_code: '601899', stock_name: '紫金矿业' },
|
||||
{ stock_code: '600309', stock_name: '万华化学' },
|
||||
{ stock_code: '002304', stock_name: '洋河股份' },
|
||||
{ stock_code: '600585', stock_name: '海螺水泥' },
|
||||
{ stock_code: '601288', stock_name: '农业银行' },
|
||||
{ stock_code: '600050', stock_name: '中国联通' },
|
||||
{ stock_code: '000001', stock_name: '平安银行' },
|
||||
{ stock_code: '601668', stock_name: '中国建筑' },
|
||||
{ stock_code: '600028', stock_name: '中国石化' },
|
||||
{ stock_code: '601857', stock_name: '中国石油' },
|
||||
{ stock_code: '600000', stock_name: '浦发银行' },
|
||||
{ stock_code: '601328', stock_name: '交通银行' },
|
||||
{ stock_code: '000002', stock_name: '万科A' },
|
||||
{ stock_code: '600104', stock_name: '上汽集团' },
|
||||
{ stock_code: '601601', stock_name: '中国太保' },
|
||||
{ stock_code: '600016', stock_name: '民生银行' },
|
||||
{ stock_code: '601628', stock_name: '中国人寿' },
|
||||
{ stock_code: '600031', stock_name: '三一重工' },
|
||||
{ stock_code: '002230', stock_name: '科大讯飞' },
|
||||
{ stock_code: '300124', stock_name: '汇川技术' },
|
||||
{ stock_code: '002049', stock_name: '紫光国微' },
|
||||
{ stock_code: '688012', stock_name: '中微公司' },
|
||||
{ stock_code: '688008', stock_name: '澜起科技' },
|
||||
{ stock_code: '603501', stock_name: '韦尔股份' },
|
||||
];
|
||||
|
||||
// 生成历史触发时间
|
||||
const generateHappenedTimes = (seed) => {
|
||||
const times = [];
|
||||
const count = 3 + (seed % 3); // 3-5个时间点
|
||||
for (let k = 0; k < count; k++) {
|
||||
const daysAgo = 30 + (seed * 7 + k * 11) % 330;
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
times.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
return times.sort().reverse();
|
||||
};
|
||||
|
||||
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
|
||||
|
||||
// 生成概念数据
|
||||
const concepts = [];
|
||||
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
|
||||
const concept = conceptPool[i];
|
||||
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
|
||||
const stockCount = Math.floor(Math.random() * 40) + 20; // 20-60只股票
|
||||
const stockCount = Math.floor(Math.random() * 20) + 15; // 15-35只股票
|
||||
|
||||
// 随机选取3-4只相关股票
|
||||
// 生成与 stockCount 一致的股票列表(包含完整字段)
|
||||
const relatedStocks = [];
|
||||
const stockIndices = new Set();
|
||||
while (stockIndices.size < Math.min(4, stockPool.length)) {
|
||||
stockIndices.add(Math.floor(Math.random() * stockPool.length));
|
||||
for (let j = 0; j < stockCount; j++) {
|
||||
const idx = (i * 7 + j) % stockPool.length;
|
||||
const stock = stockPool[idx];
|
||||
relatedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.stock_name,
|
||||
reason: `作为行业龙头企业,${stock.stock_name}在该领域具有核心竞争优势,市场份额领先。`,
|
||||
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
|
||||
});
|
||||
}
|
||||
stockIndices.forEach(idx => relatedStocks.push(stockPool[idx]));
|
||||
|
||||
concepts.push({
|
||||
concept_id: `CONCEPT_${1001 + i}`,
|
||||
concept_name: concept.name,
|
||||
change_percent: changePercent,
|
||||
stock_count: stockCount,
|
||||
concept: concept.name, // 原始字段名
|
||||
concept_name: concept.name, // 兼容字段名
|
||||
description: concept.desc,
|
||||
stocks: relatedStocks
|
||||
stock_count: stockCount,
|
||||
score: parseFloat((Math.random() * 5 + 3).toFixed(2)), // 3-8 分数
|
||||
match_type: matchTypes[i % 3],
|
||||
price_info: {
|
||||
avg_change_pct: changePercent,
|
||||
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
|
||||
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
|
||||
},
|
||||
change_percent: changePercent, // 兼容字段
|
||||
happened_times: generateHappenedTimes(i),
|
||||
stocks: relatedStocks,
|
||||
hot_score: Math.floor(Math.random() * 100)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ import MiniTimelineChart from './MiniTimelineChart';
|
||||
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '../utils/klineDataCache';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 标准化股票代码为6位格式
|
||||
* @param {string} code - 股票代码
|
||||
* @returns {string} 6位标准化代码
|
||||
*/
|
||||
const normalizeStockCode = (code) => {
|
||||
if (!code) return '';
|
||||
const s = String(code).trim();
|
||||
const m = s.match(/(\d{6})/);
|
||||
return m ? m[1] : s;
|
||||
};
|
||||
|
||||
/**
|
||||
* 股票列表表格组件
|
||||
* 显示事件相关股票列表,包括分时图、涨跌幅、自选股操作等
|
||||
@@ -260,7 +272,9 @@ const StockTable = ({
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const isInWatchlist = watchlistSet.has(record.stock_code);
|
||||
// 标准化代码后再比较,确保 600000.SH 和 600000 能匹配
|
||||
const normalizedCode = normalizeStockCode(record.stock_code);
|
||||
const isInWatchlist = watchlistSet.has(normalizedCode);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
|
||||
@@ -5,6 +5,27 @@ import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../.
|
||||
import { message } from 'antd';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 标准化股票代码为6位格式
|
||||
* 支持: 600000, 600000.SH, 600000.SZ, SH600000 等格式
|
||||
* @param {string} code - 股票代码
|
||||
* @returns {string} 6位标准化代码
|
||||
*/
|
||||
const normalizeStockCode = (code) => {
|
||||
if (!code) return '';
|
||||
const s = String(code).trim().toUpperCase();
|
||||
// 匹配6位数字(可能带 .SH/.SZ 后缀)
|
||||
const m1 = s.match(/^(\d{6})(?:\.(?:SH|SZ))?$/i);
|
||||
if (m1) return m1[1];
|
||||
// 匹配 SH/SZ 前缀格式
|
||||
const m2 = s.match(/^(?:SH|SZ)(\d{6})$/i);
|
||||
if (m2) return m2[1];
|
||||
// 尝试提取任意6位数字
|
||||
const m3 = s.match(/(\d{6})/);
|
||||
if (m3) return m3[1];
|
||||
return s;
|
||||
};
|
||||
|
||||
/**
|
||||
* 自选股管理 Hook
|
||||
* 封装自选股的加载、添加、移除逻辑
|
||||
@@ -19,9 +40,9 @@ export const useWatchlist = (shouldLoad = true) => {
|
||||
const watchlistArray = useSelector(state => state.stock.watchlist, shallowEqual);
|
||||
const loading = useSelector(state => state.stock.loading.watchlist);
|
||||
|
||||
// 转换为 Set 方便快速查询
|
||||
// 转换为 Set 方便快速查询(标准化为6位代码)
|
||||
const watchlistSet = useMemo(() => {
|
||||
return new Set(watchlistArray);
|
||||
return new Set(watchlistArray.map(normalizeStockCode));
|
||||
}, [watchlistArray]);
|
||||
|
||||
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
|
||||
@@ -33,32 +54,36 @@ export const useWatchlist = (shouldLoad = true) => {
|
||||
}, [dispatch, shouldLoad]);
|
||||
|
||||
/**
|
||||
* 检查股票是否在自选股中
|
||||
* @param {string} stockCode - 股票代码
|
||||
* 检查股票是否在自选股中(支持带后缀的代码格式)
|
||||
* @param {string} stockCode - 股票代码(支持 600000, 600000.SH 等格式)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isInWatchlist = useCallback((stockCode) => {
|
||||
return watchlistSet.has(stockCode);
|
||||
const normalized = normalizeStockCode(stockCode);
|
||||
return watchlistSet.has(normalized);
|
||||
}, [watchlistSet]);
|
||||
|
||||
/**
|
||||
* 切换自选股状态
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} stockCode - 股票代码(支持带后缀格式,会自动标准化)
|
||||
* @param {string} stockName - 股票名称
|
||||
* @returns {Promise<boolean>} 操作是否成功
|
||||
*/
|
||||
const toggleWatchlist = useCallback(async (stockCode, stockName) => {
|
||||
const wasInWatchlist = watchlistSet.has(stockCode);
|
||||
const normalized = normalizeStockCode(stockCode);
|
||||
const wasInWatchlist = watchlistSet.has(normalized);
|
||||
|
||||
logger.debug('useWatchlist', '切换自选股状态', {
|
||||
stockCode,
|
||||
normalized,
|
||||
stockName,
|
||||
wasInWatchlist
|
||||
});
|
||||
|
||||
try {
|
||||
// 传递标准化后的6位代码给 Redux action
|
||||
await dispatch(toggleWatchlistAction({
|
||||
stockCode,
|
||||
stockCode: normalized,
|
||||
stockName,
|
||||
isInWatchlist: wasInWatchlist
|
||||
})).unwrap();
|
||||
@@ -68,6 +93,7 @@ export const useWatchlist = (shouldLoad = true) => {
|
||||
} catch (error) {
|
||||
logger.error('useWatchlist', '切换自选股失败', error, {
|
||||
stockCode,
|
||||
normalized,
|
||||
stockName
|
||||
});
|
||||
message.error(error.message || '操作失败,请稍后重试');
|
||||
@@ -87,16 +113,17 @@ export const useWatchlist = (shouldLoad = true) => {
|
||||
|
||||
let successCount = 0;
|
||||
const promises = stocks.map(async ({ code, name }) => {
|
||||
if (!watchlistSet.has(code)) {
|
||||
const normalized = normalizeStockCode(code);
|
||||
if (!watchlistSet.has(normalized)) {
|
||||
try {
|
||||
await dispatch(toggleWatchlistAction({
|
||||
stockCode: code,
|
||||
stockCode: normalized,
|
||||
stockName: name,
|
||||
isInWatchlist: false
|
||||
})).unwrap();
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
logger.error('useWatchlist', '添加失败', error, { code, name });
|
||||
logger.error('useWatchlist', '添加失败', error, { code, normalized, name });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1463,7 +1463,7 @@ const ConceptCenter = () => {
|
||||
fontSize="md"
|
||||
transition="all 0.2s"
|
||||
border="none"
|
||||
height="100%"
|
||||
alignSelf="stretch"
|
||||
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
||||
>
|
||||
搜索
|
||||
|
||||
233
src/views/StockOverview/components/ConceptStocksModal.tsx
Normal file
233
src/views/StockOverview/components/ConceptStocksModal.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaTable } from 'react-icons/fa';
|
||||
import marketService from '@services/marketService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// 股票信息类型
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 概念信息类型
|
||||
export interface ConceptInfo {
|
||||
concept_id?: string;
|
||||
concept_name: string;
|
||||
stock_count?: number;
|
||||
stocks?: StockInfo[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 行情数据类型
|
||||
interface MarketData {
|
||||
stock_code: string;
|
||||
close?: number;
|
||||
change_percent?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConceptStocksModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
concept: ConceptInfo | null;
|
||||
}
|
||||
|
||||
const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
concept,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态
|
||||
const [stockMarketData, setStockMarketData] = useState<Record<string, MarketData>>({});
|
||||
const [loadingStockData, setLoadingStockData] = useState(false);
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||
|
||||
// 批量获取股票行情数据
|
||||
const fetchStockMarketData = useCallback(async (stocks: StockInfo[]) => {
|
||||
if (!stocks || stocks.length === 0) return;
|
||||
|
||||
setLoadingStockData(true);
|
||||
const newMarketData: Record<string, MarketData> = {};
|
||||
|
||||
try {
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < stocks.length; i += batchSize) {
|
||||
const batch = stocks.slice(i, i + batchSize);
|
||||
const promises = batch.map(async (stock) => {
|
||||
if (!stock.stock_code) return null;
|
||||
const seccode = stock.stock_code.substring(0, 6);
|
||||
try {
|
||||
const response = await marketService.getTradeData(seccode, 1);
|
||||
if (response.success && response.data?.length > 0) {
|
||||
const latestData = response.data[response.data.length - 1];
|
||||
return { stock_code: stock.stock_code, ...latestData };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('ConceptStocksModal', '获取股票行情失败', { stockCode: seccode });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach((result) => {
|
||||
if (result) newMarketData[result.stock_code] = result;
|
||||
});
|
||||
}
|
||||
setStockMarketData(newMarketData);
|
||||
} catch (error) {
|
||||
logger.error('ConceptStocksModal', 'fetchStockMarketData', error);
|
||||
} finally {
|
||||
setLoadingStockData(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 弹窗打开时加载数据
|
||||
React.useEffect(() => {
|
||||
if (isOpen && concept?.stocks) {
|
||||
setStockMarketData({});
|
||||
fetchStockMarketData(concept.stocks);
|
||||
}
|
||||
}, [isOpen, concept, fetchStockMarketData]);
|
||||
|
||||
// 点击股票行
|
||||
const handleStockClick = (stockCode: string) => {
|
||||
navigate(`/company?scode=${stockCode}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const stocks = concept?.stocks || [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={cardBg}>
|
||||
<ModalHeader bg="purple.500" color="white" borderTopRadius="md">
|
||||
<HStack>
|
||||
<Icon as={FaTable} />
|
||||
<Text>{concept?.concept_name} - 相关个股</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
|
||||
<ModalBody py={6}>
|
||||
{stocks.length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center">暂无相关股票数据</Text>
|
||||
) : (
|
||||
<Box>
|
||||
{loadingStockData && (
|
||||
<HStack justify="center" mb={4}>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
<Text fontSize="sm" color="gray.500">正在获取行情数据...</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<TableContainer maxH="60vh" overflowY="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
|
||||
<Tr>
|
||||
<Th>股票名称</Th>
|
||||
<Th>股票代码</Th>
|
||||
<Th isNumeric>现价</Th>
|
||||
<Th isNumeric>涨跌幅</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{stocks.map((stock, idx) => {
|
||||
const marketData = stockMarketData[stock.stock_code];
|
||||
const changePercent = marketData?.change_percent;
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={idx}
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
onClick={() => handleStockClick(stock.stock_code)}
|
||||
>
|
||||
<Td color="blue.500" fontWeight="medium">
|
||||
{stock.stock_name}
|
||||
</Td>
|
||||
<Td>{stock.stock_code}</Td>
|
||||
<Td isNumeric>
|
||||
{loadingStockData ? (
|
||||
<Spinner size="xs" />
|
||||
) : marketData?.close ? (
|
||||
`¥${marketData.close.toFixed(2)}`
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
<Td
|
||||
isNumeric
|
||||
fontWeight="bold"
|
||||
color={
|
||||
changePercent && changePercent > 0
|
||||
? 'red.500'
|
||||
: changePercent && changePercent < 0
|
||||
? 'green.500'
|
||||
: 'gray.500'
|
||||
}
|
||||
>
|
||||
{loadingStockData ? (
|
||||
<Spinner size="xs" />
|
||||
) : changePercent !== undefined ? (
|
||||
`${changePercent > 0 ? '+' : ''}${changePercent.toFixed(2)}%`
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button colorScheme="purple" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptStocksModal;
|
||||
@@ -56,10 +56,15 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
||||
import ConceptStocksModal from './components/ConceptStocksModal';
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
import tradingDays from '../../data/tradingDays.json';
|
||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||
|
||||
// 交易日 Set,用于快速查找
|
||||
const tradingDaysSet = new Set(tradingDays);
|
||||
// Navigation bar now provided by MainLayout
|
||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
|
||||
@@ -98,6 +103,10 @@ const StockOverview = () => {
|
||||
const [availableDates, setAvailableDates] = useState([]);
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
// 个股列表弹窗状态
|
||||
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||||
const [selectedConcept, setSelectedConcept] = useState(null);
|
||||
|
||||
// 专业的颜色主题
|
||||
const bgColor = useColorModeValue('white', '#0a0a0a');
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
@@ -110,6 +119,13 @@ const StockOverview = () => {
|
||||
const accentColor = useColorModeValue('purple.600', goldColor);
|
||||
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
|
||||
|
||||
// 打开个股列表弹窗
|
||||
const handleViewStocks = useCallback((e, concept) => {
|
||||
e.stopPropagation();
|
||||
setSelectedConcept(concept);
|
||||
setIsStockModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 防抖搜索
|
||||
const debounceSearch = useCallback(
|
||||
(() => {
|
||||
@@ -173,7 +189,27 @@ const StockOverview = () => {
|
||||
|
||||
if (data.success) {
|
||||
setTopConcepts(data.data);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 使用概念接口的日期作为统一数据源(数据最新)
|
||||
setSelectedDate(data.trade_date);
|
||||
// 基于交易日历生成可选日期列表
|
||||
if (data.trade_date && tradingDays.length > 0) {
|
||||
// 找到当前日期或最近的交易日
|
||||
let targetDate = data.trade_date;
|
||||
if (!tradingDaysSet.has(data.trade_date)) {
|
||||
for (let i = tradingDays.length - 1; i >= 0; i--) {
|
||||
if (tradingDays[i] <= data.trade_date) {
|
||||
targetDate = tradingDays[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const idx = tradingDays.indexOf(targetDate);
|
||||
if (idx !== -1) {
|
||||
const startIdx = Math.max(0, idx - 19);
|
||||
const dates = tradingDays.slice(startIdx, idx + 1).reverse();
|
||||
setAvailableDates(dates);
|
||||
}
|
||||
}
|
||||
logger.debug('StockOverview', '热门概念加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
date: data.trade_date
|
||||
@@ -204,7 +240,7 @@ const StockOverview = () => {
|
||||
falling_count: data.statistics.falling_count
|
||||
}));
|
||||
}
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 日期由 fetchTopConcepts 统一设置,这里不再设置
|
||||
logger.debug('StockOverview', '热力图数据加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
date: data.trade_date
|
||||
@@ -235,11 +271,9 @@ const StockOverview = () => {
|
||||
date: data.trade_date
|
||||
};
|
||||
setMarketStats(newStats);
|
||||
setAvailableDates(data.available_dates || []);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
|
||||
logger.debug('StockOverview', '市场统计数据加载成功', {
|
||||
date: data.trade_date,
|
||||
availableDatesCount: data.available_dates?.length || 0
|
||||
date: data.trade_date
|
||||
});
|
||||
|
||||
// 🎯 追踪市场统计数据查看
|
||||
@@ -974,31 +1008,33 @@ const StockOverview = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box w="100%">
|
||||
<Box
|
||||
w="100%"
|
||||
cursor="pointer"
|
||||
onClick={(e) => handleViewStocks(e, concept)}
|
||||
_hover={{ bg: hoverBg }}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
包含 {concept.stock_count} 只个股
|
||||
</Text>
|
||||
|
||||
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Flex flexWrap="wrap" gap={2}>
|
||||
<Flex
|
||||
flexWrap="nowrap"
|
||||
gap={2}
|
||||
overflow="hidden"
|
||||
maxH="24px"
|
||||
>
|
||||
{concept.stocks.map((stock, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 🎯 追踪概念下的股票标签点击
|
||||
trackConceptStockClicked({
|
||||
code: stock.stock_code,
|
||||
name: stock.stock_name
|
||||
}, concept.concept_name);
|
||||
|
||||
navigate(`/company?scode=${stock.stock_code}`);
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<TagLabel>{stock.stock_name}</TagLabel>
|
||||
</Tag>
|
||||
@@ -1098,7 +1134,14 @@ const StockOverview = () => {
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
|
||||
{/* 个股列表弹窗 */}
|
||||
<ConceptStocksModal
|
||||
isOpen={isStockModalOpen}
|
||||
onClose={() => setIsStockModalOpen(false)}
|
||||
concept={selectedConcept}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user