feat: 热门关键词UI调整 数据获取逻辑调整 接入redux

This commit is contained in:
zdl
2025-10-25 18:22:41 +08:00
parent 873adda1fd
commit 094793c022
7 changed files with 557 additions and 111 deletions

View File

@@ -20,6 +20,7 @@
"@fullcalendar/react": "^5.9.0",
"@react-three/drei": "^9.11.3",
"@react-three/fiber": "^8.0.27",
"@reduxjs/toolkit": "^2.9.2",
"@splidejs/react-splide": "^0.7.12",
"@tippyjs/react": "^4.2.6",
"@visx/visx": "^3.12.0",
@@ -59,6 +60,7 @@
"react-leaflet": "^3.2.5",
"react-markdown": "^10.1.0",
"react-quill": "^2.0.0-beta.4",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.1",
"react-responsive-masonry": "^2.7.1",
"react-router-dom": "^6.30.1",

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.11.5'
const PACKAGE_VERSION = '2.11.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -40,6 +40,10 @@ const StockOverview = React.lazy(() => import("views/StockOverview"));
const EventDetail = React.lazy(() => import("views/EventDetail"));
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// Redux
import { Provider as ReduxProvider } from 'react-redux';
import { store } from './store';
// Contexts
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
@@ -291,30 +295,32 @@ export default function App() {
}, []);
return (
<ChakraProvider
theme={theme}
toastOptions={{
defaultOptions: {
position: 'top',
duration: 3000,
isClosable: true,
}
}}
>
<ErrorBoundary>
<NotificationProvider>
<AuthProvider>
<AuthModalProvider>
<IndustryProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</IndustryProvider>
</AuthModalProvider>
<ReduxProvider store={store}>
<ChakraProvider
theme={theme}
toastOptions={{
defaultOptions: {
position: 'top',
duration: 3000,
isClosable: true,
}
}}
>
<ErrorBoundary>
<NotificationProvider>
<AuthProvider>
<AuthModalProvider>
<IndustryProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</IndustryProvider>
</AuthModalProvider>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>
</ChakraProvider>
</ReduxProvider>
);
}

View File

@@ -0,0 +1,101 @@
// src/services/stockService.js
// 股票数据服务
import { logger } from '../utils/logger';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
/**
* 股票数据服务
*/
export const stockService = {
/**
* 获取所有股票列表
* @returns {Promise<{success: boolean, data: Array<{code: string, name: string}>}>}
*/
async getAllStocks() {
try {
const response = await fetch(`${API_BASE_URL}/api/stocklist`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
logger.debug('stockService', 'getAllStocks 成功', {
count: data?.length || 0
});
return {
success: true,
data: data || []
};
} catch (error) {
logger.error('stockService', 'getAllStocks', error);
return {
success: false,
data: [],
error: error.message
};
}
},
/**
* 模糊搜索股票(匹配 code 或 name
* @param {string} query - 搜索关键词
* @param {Array<{code: string, name: string}>} stockList - 股票列表
* @param {number} limit - 返回结果数量限制
* @returns {Array<{code: string, name: string}>}
*/
fuzzySearch(query, stockList, limit = 10) {
if (!query || !stockList || stockList.length === 0) {
return [];
}
const lowerQuery = query.toLowerCase();
// 模糊匹配 code 或 name
const results = stockList.filter(stock => {
const code = (stock.code || '').toString().toLowerCase();
const name = (stock.name || '').toLowerCase();
return code.includes(lowerQuery) || name.includes(lowerQuery);
});
// 优先级排序:
// 1. code 精确匹配
// 2. name 精确匹配
// 3. code 开头匹配
// 4. name 开头匹配
// 5. 其他包含匹配
results.sort((a, b) => {
const aCode = (a.code || '').toString().toLowerCase();
const aName = (a.name || '').toLowerCase();
const bCode = (b.code || '').toString().toLowerCase();
const bName = (b.name || '').toLowerCase();
// 精确匹配
if (aCode === lowerQuery) return -1;
if (bCode === lowerQuery) return 1;
if (aName === lowerQuery) return -1;
if (bName === lowerQuery) return 1;
// 开头匹配
if (aCode.startsWith(lowerQuery) && !bCode.startsWith(lowerQuery)) return -1;
if (!aCode.startsWith(lowerQuery) && bCode.startsWith(lowerQuery)) return 1;
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1;
if (!aName.startsWith(lowerQuery) && bName.startsWith(lowerQuery)) return 1;
// 字母顺序
return aCode.localeCompare(bCode);
});
return results.slice(0, limit);
}
};

18
src/store/index.js Normal file
View File

@@ -0,0 +1,18 @@
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import communityDataReducer from './slices/communityDataSlice';
export const store = configureStore({
reducer: {
communityData: communityDataReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// 忽略这些 action types 的序列化检查
ignoredActions: ['communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled'],
},
}),
});
export default store;

View File

@@ -0,0 +1,321 @@
// src/store/slices/communityDataSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { eventService } from '../../services/eventService';
import { logger } from '../../utils/logger';
// ==================== 常量定义 ====================
// 缓存键名
const CACHE_KEYS = {
POPULAR_KEYWORDS: 'community_popular_keywords',
HOT_EVENTS: 'community_hot_events'
};
// 缓存有效期类型
const CACHE_EXPIRY_TYPE = {
MIDNIGHT: 'midnight', // 当天午夜过期
HOURS: 'hours' // 指定小时数后过期
};
// ==================== 缓存工具函数 ====================
/**
* 计算缓存过期时间
* @param {string} type - 过期类型 (midnight/hours)
* @param {number} hours - 小时数(仅当 type 为 hours 时使用)
* @returns {string} ISO 格式的过期时间
*/
const getExpireTime = (type = CACHE_EXPIRY_TYPE.MIDNIGHT, hours = 24) => {
const expireDate = new Date();
if (type === CACHE_EXPIRY_TYPE.MIDNIGHT) {
// 设置为明天凌晨 0 点
expireDate.setDate(expireDate.getDate() + 1);
expireDate.setHours(0, 0, 0, 0);
} else {
// 设置为指定小时后
expireDate.setHours(expireDate.getHours() + hours);
}
return expireDate.toISOString();
};
/**
* 从 localStorage 获取缓存数据
* @param {string} key - 缓存键名
* @returns {any|null} 缓存的数据或 null
*/
const getCachedData = (key) => {
try {
const cached = localStorage.getItem(key);
if (!cached) return null;
const { data, expireAt } = JSON.parse(cached);
// 检查是否过期
if (new Date() > new Date(expireAt)) {
localStorage.removeItem(key);
logger.debug('CommunityData', '缓存已过期', { key });
return null;
}
logger.debug('CommunityData', '使用缓存数据', {
key,
dataLength: Array.isArray(data) ? data.length : 'N/A',
expireAt
});
return data;
} catch (error) {
logger.error('CommunityData', 'getCachedData 失败', error, { key });
// 清除损坏的缓存
localStorage.removeItem(key);
return null;
}
};
/**
* 保存数据到 localStorage
* @param {string} key - 缓存键名
* @param {any} data - 要缓存的数据
* @param {string} expiryType - 过期类型
*/
const setCachedData = (key, data, expiryType = CACHE_EXPIRY_TYPE.MIDNIGHT) => {
try {
const cacheData = {
data,
expireAt: getExpireTime(expiryType),
cachedAt: new Date().toISOString()
};
localStorage.setItem(key, JSON.stringify(cacheData));
logger.debug('CommunityData', '数据已缓存', {
key,
dataLength: Array.isArray(data) ? data.length : 'N/A',
expireAt: cacheData.expireAt,
cachedAt: cacheData.cachedAt
});
} catch (error) {
logger.error('CommunityData', 'setCachedData 失败', error, { key });
// localStorage 可能已满,尝试清理
if (error.name === 'QuotaExceededError') {
logger.warn('CommunityData', 'localStorage 配额已满,尝试清理旧缓存');
Object.values(CACHE_KEYS).forEach(cacheKey => {
localStorage.removeItem(cacheKey);
});
}
}
};
// ==================== 通用数据获取逻辑 ====================
/**
* 通用的数据获取函数支持三级缓存Redux -> localStorage -> API
* @param {Object} options - 配置选项
* @param {string} options.cacheKey - 缓存键名
* @param {Function} options.fetchFn - API 获取函数
* @param {Function} options.getState - Redux getState 函数
* @param {string} options.stateKey - Redux state 中的键名
* @param {boolean} options.forceRefresh - 是否强制刷新
* @returns {Promise<any>} 获取的数据
*/
const fetchWithCache = async ({
cacheKey,
fetchFn,
getState,
stateKey,
forceRefresh = false
}) => {
try {
// 第一级缓存:检查 Redux 状态(除非强制刷新)
if (!forceRefresh) {
const stateData = getState().communityData[stateKey];
if (stateData && stateData.length > 0) {
logger.debug('CommunityData', `Redux 状态中已有${stateKey}数据`);
return stateData;
}
// 第二级缓存:检查 localStorage
const cachedData = getCachedData(cacheKey);
if (cachedData) {
return cachedData;
}
}
// 第三级:从 API 获取
logger.debug('CommunityData', `从 API 获取${stateKey}`, { forceRefresh });
const response = await fetchFn();
if (response.success && response.data) {
// 保存到 localStorage
setCachedData(cacheKey, response.data);
return response.data;
}
logger.warn('CommunityData', `API 返回数据为空:${stateKey}`);
return [];
} catch (error) {
logger.error('CommunityData', `获取${stateKey}失败`, error);
throw error;
}
};
// ==================== Async Thunks ====================
/**
* 获取热门关键词
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
*/
export const fetchPopularKeywords = createAsyncThunk(
'communityData/fetchPopularKeywords',
async (forceRefresh = false, { getState, rejectWithValue }) => {
try {
return await fetchWithCache({
cacheKey: CACHE_KEYS.POPULAR_KEYWORDS,
fetchFn: () => eventService.getPopularKeywords(20),
getState,
stateKey: 'popularKeywords',
forceRefresh
});
} catch (error) {
return rejectWithValue(error.message || '获取热门关键词失败');
}
}
);
/**
* 获取热点事件
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
*/
export const fetchHotEvents = createAsyncThunk(
'communityData/fetchHotEvents',
async (forceRefresh = false, { getState, rejectWithValue }) => {
try {
return await fetchWithCache({
cacheKey: CACHE_KEYS.HOT_EVENTS,
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 4 }),
getState,
stateKey: 'hotEvents',
forceRefresh
});
} catch (error) {
return rejectWithValue(error.message || '获取热点事件失败');
}
}
);
// ==================== Slice 定义 ====================
const communityDataSlice = createSlice({
name: 'communityData',
initialState: {
// 数据
popularKeywords: [],
hotEvents: [],
// 加载状态
loading: {
popularKeywords: false,
hotEvents: false
},
// 错误信息
error: {
popularKeywords: null,
hotEvents: null
},
// 最后更新时间
lastUpdated: {
popularKeywords: null,
hotEvents: null
}
},
reducers: {
/**
* 清除所有缓存Redux + localStorage
*/
clearCache: (state) => {
// 清除 localStorage
Object.values(CACHE_KEYS).forEach(key => {
localStorage.removeItem(key);
});
// 清除 Redux 状态
state.popularKeywords = [];
state.hotEvents = [];
state.lastUpdated.popularKeywords = null;
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '所有缓存已清除');
},
/**
* 清除指定类型的缓存
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
*/
clearSpecificCache: (state, action) => {
const type = action.payload;
if (type === 'popularKeywords') {
localStorage.removeItem(CACHE_KEYS.POPULAR_KEYWORDS);
state.popularKeywords = [];
state.lastUpdated.popularKeywords = null;
logger.info('CommunityData', '热门关键词缓存已清除');
} else if (type === 'hotEvents') {
localStorage.removeItem(CACHE_KEYS.HOT_EVENTS);
state.hotEvents = [];
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '热点事件缓存已清除');
}
}
},
extraReducers: (builder) => {
// ===== 热门关键词 =====
builder
.addCase(fetchPopularKeywords.pending, (state) => {
state.loading.popularKeywords = true;
state.error.popularKeywords = null;
})
.addCase(fetchPopularKeywords.fulfilled, (state, action) => {
state.loading.popularKeywords = false;
state.popularKeywords = action.payload;
state.lastUpdated.popularKeywords = new Date().toISOString();
})
.addCase(fetchPopularKeywords.rejected, (state, action) => {
state.loading.popularKeywords = false;
state.error.popularKeywords = action.payload;
logger.error('CommunityData', '热门关键词加载失败', new Error(action.payload));
});
// ===== 热点事件 =====
builder
.addCase(fetchHotEvents.pending, (state) => {
state.loading.hotEvents = true;
state.error.hotEvents = null;
})
.addCase(fetchHotEvents.fulfilled, (state, action) => {
state.loading.hotEvents = false;
state.hotEvents = action.payload;
state.lastUpdated.hotEvents = new Date().toISOString();
})
.addCase(fetchHotEvents.rejected, (state, action) => {
state.loading.hotEvents = false;
state.error.hotEvents = action.payload;
logger.error('CommunityData', '热点事件加载失败', new Error(action.payload));
});
}
});
// ==================== 导出 ====================
export const { clearCache, clearSpecificCache } = communityDataSlice.actions;
// 选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
export const selectHotEvents = (state) => state.communityData.hotEvents;
export const selectLoading = (state) => state.communityData.loading;
export const selectError = (state) => state.communityData.error;
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
export default communityDataSlice.reducer;

View File

@@ -1,12 +1,12 @@
// src/views/Community/components/PopularKeywords.js
import React, { useState, useEffect } from 'react';
import { Card, Tag, Space, Spin, Empty, Button } from 'antd';
import { FireOutlined, RightOutlined } from '@ant-design/icons';
import { Tag, Space, Spin, Button } from 'antd';
import { useNavigate } from 'react-router-dom';
import { RightOutlined } from '@ant-design/icons';
import { logger } from '../../../utils/logger';
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '/concept-api'
: 'http://192.168.1.58:6801';
// 使用相对路径,让 MSW 在开发环境可以拦截请求
const API_BASE_URL = '/concept-api';
// 获取域名前缀
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
@@ -16,6 +16,7 @@ const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
const PopularKeywords = ({ onKeywordClick }) => {
const [keywords, setKeywords] = useState([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
// 加载热门概念涨幅前20
const loadPopularConcepts = async () => {
@@ -93,97 +94,94 @@ const PopularKeywords = ({ onKeywordClick }) => {
window.open(url, '_blank');
};
// 查看更多概念
const handleViewMore = () => {
const url = `${DOMAIN_PREFIX}/concepts`;
window.open(url, '_blank');
// 处理"更多概念"按钮点击 - 跳转到概念中心
const handleMoreClick = () => {
navigate('/concepts');
};
return (
<Card
title={
<span>
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
热门概念
</span>
}
className="popular-keywords"
style={{ marginBottom: 16 }}
extra={
<span style={{ fontSize: 12, color: '#999' }}>
涨幅TOP20
</span>
}
>
<Spin spinning={loading}>
{keywords && keywords.length > 0 ? (
<>
<Space size={[8, 8]} wrap style={{ marginBottom: 16 }}>
{keywords.map((item) => (
<Tag
key={item.concept_id}
color={getTagColor(item.change_pct)}
style={{
cursor: 'pointer',
marginBottom: 8,
padding: '2px 8px',
transition: 'all 0.3s'
}}
onClick={() => handleConceptClick(item)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span>{item.keyword}</span>
<span style={{
marginLeft: 6,
fontWeight: 'bold'
}}>
{formatChangePct(item.change_pct)}
</span>
<span style={{
marginLeft: 4,
fontSize: 11,
opacity: 0.8
}}>
({item.count})
</span>
</Tag>
))}
</Space>
{/* 查看更多按钮 */}
<div style={{
borderTop: '1px solid #f0f0f0',
paddingTop: 12,
textAlign: 'center'
<Spin spinning={loading}>
{keywords && keywords.length > 0 && (
<div style={{ position: 'relative' }}>
<Space
size={[6, 6]}
wrap
style={{
alignItems: 'center',
maxHeight: '62px', // 约两行的高度 (每行约28-30px)
overflow: 'hidden',
paddingRight: '90px' // 为右侧按钮留出空间
}}
>
{/* 标题 */}
<span style={{
color: '#ff4d4f',
fontSize: 13,
fontWeight: 500,
marginRight: 4
}}>
<Button
type="link"
onClick={handleViewMore}
热门概念:
</span>
{/* 所有标签 */}
{keywords.map((item) => (
<Tag
key={item.concept_id}
color={getTagColor(item.change_pct)}
style={{
color: '#1890ff',
fontWeight: 500
cursor: 'pointer',
padding: '1px 6px',
fontSize: 12,
transition: 'all 0.3s',
margin: 0
}}
onClick={() => handleConceptClick(item)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
查看更多概念
<RightOutlined style={{ fontSize: 12, marginLeft: 4 }} />
</Button>
</div>
</>
) : (
<Empty
description="暂无热门概念"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Spin>
</Card>
<span>{item.keyword}</span>
<span style={{
marginLeft: 4,
fontWeight: 'bold'
}}>
{formatChangePct(item.change_pct)}
</span>
<span style={{
marginLeft: 3,
fontSize: 10,
opacity: 0.75
}}>
({item.count})
</span>
</Tag>
))}
</Space>
{/* 更多概念按钮 - 固定在第二行右侧 */}
<Button
type="link"
size="small"
onClick={handleMoreClick}
style={{
position: 'absolute',
bottom: 0,
right: 0,
fontSize: 12,
padding: '0 4px',
height: 'auto'
}}
>
更多概念 <RightOutlined style={{ fontSize: 10 }} />
</Button>
</div>
)}
</Spin>
);
};