feat: 热门关键词UI调整 数据获取逻辑调整 接入redux
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"@fullcalendar/react": "^5.9.0",
|
"@fullcalendar/react": "^5.9.0",
|
||||||
"@react-three/drei": "^9.11.3",
|
"@react-three/drei": "^9.11.3",
|
||||||
"@react-three/fiber": "^8.0.27",
|
"@react-three/fiber": "^8.0.27",
|
||||||
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
"@splidejs/react-splide": "^0.7.12",
|
"@splidejs/react-splide": "^0.7.12",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@visx/visx": "^3.12.0",
|
"@visx/visx": "^3.12.0",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"react-leaflet": "^3.2.5",
|
"react-leaflet": "^3.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-quill": "^2.0.0-beta.4",
|
"react-quill": "^2.0.0-beta.4",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
"react-responsive": "^10.0.1",
|
"react-responsive": "^10.0.1",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
"react-responsive-masonry": "^2.7.1",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - Please do NOT modify this file.
|
* - Please do NOT modify this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PACKAGE_VERSION = '2.11.5'
|
const PACKAGE_VERSION = '2.11.6'
|
||||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
const activeClientIds = new Set()
|
const activeClientIds = new Set()
|
||||||
|
|||||||
48
src/App.js
48
src/App.js
@@ -40,6 +40,10 @@ const StockOverview = React.lazy(() => import("views/StockOverview"));
|
|||||||
const EventDetail = React.lazy(() => import("views/EventDetail"));
|
const EventDetail = React.lazy(() => import("views/EventDetail"));
|
||||||
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
import { store } from './store';
|
||||||
|
|
||||||
// Contexts
|
// Contexts
|
||||||
import { AuthProvider } from "contexts/AuthContext";
|
import { AuthProvider } from "contexts/AuthContext";
|
||||||
import { AuthModalProvider } from "contexts/AuthModalContext";
|
import { AuthModalProvider } from "contexts/AuthModalContext";
|
||||||
@@ -291,30 +295,32 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChakraProvider
|
<ReduxProvider store={store}>
|
||||||
theme={theme}
|
<ChakraProvider
|
||||||
toastOptions={{
|
theme={theme}
|
||||||
defaultOptions: {
|
toastOptions={{
|
||||||
position: 'top',
|
defaultOptions: {
|
||||||
duration: 3000,
|
position: 'top',
|
||||||
isClosable: true,
|
duration: 3000,
|
||||||
}
|
isClosable: true,
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<ErrorBoundary>
|
>
|
||||||
<NotificationProvider>
|
<ErrorBoundary>
|
||||||
<AuthProvider>
|
<NotificationProvider>
|
||||||
<AuthModalProvider>
|
<AuthProvider>
|
||||||
<IndustryProvider>
|
<AuthModalProvider>
|
||||||
<AppContent />
|
<IndustryProvider>
|
||||||
<AuthModalManager />
|
<AppContent />
|
||||||
<NotificationContainer />
|
<AuthModalManager />
|
||||||
<NotificationTestTool />
|
<NotificationContainer />
|
||||||
</IndustryProvider>
|
<NotificationTestTool />
|
||||||
</AuthModalProvider>
|
</IndustryProvider>
|
||||||
|
</AuthModalProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ChakraProvider>
|
</ChakraProvider>
|
||||||
|
</ReduxProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
101
src/services/stockService.js
Normal file
101
src/services/stockService.js
Normal 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
18
src/store/index.js
Normal 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;
|
||||||
321
src/store/slices/communityDataSlice.js
Normal file
321
src/store/slices/communityDataSlice.js
Normal 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;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// src/views/Community/components/PopularKeywords.js
|
// src/views/Community/components/PopularKeywords.js
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, Tag, Space, Spin, Empty, Button } from 'antd';
|
import { Tag, Space, Spin, Button } from 'antd';
|
||||||
import { FireOutlined, RightOutlined } from '@ant-design/icons';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { RightOutlined } from '@ant-design/icons';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
// 使用相对路径,让 MSW 在开发环境可以拦截请求
|
||||||
? '/concept-api'
|
const API_BASE_URL = '/concept-api';
|
||||||
: 'http://192.168.1.58:6801';
|
|
||||||
|
|
||||||
// 获取域名前缀
|
// 获取域名前缀
|
||||||
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
|
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
|
||||||
@@ -16,6 +16,7 @@ const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
|
|||||||
const PopularKeywords = ({ onKeywordClick }) => {
|
const PopularKeywords = ({ onKeywordClick }) => {
|
||||||
const [keywords, setKeywords] = useState([]);
|
const [keywords, setKeywords] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 加载热门概念(涨幅前20)
|
// 加载热门概念(涨幅前20)
|
||||||
const loadPopularConcepts = async () => {
|
const loadPopularConcepts = async () => {
|
||||||
@@ -93,97 +94,94 @@ const PopularKeywords = ({ onKeywordClick }) => {
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查看更多概念
|
// 处理"更多概念"按钮点击 - 跳转到概念中心
|
||||||
const handleViewMore = () => {
|
const handleMoreClick = () => {
|
||||||
const url = `${DOMAIN_PREFIX}/concepts`;
|
navigate('/concepts');
|
||||||
window.open(url, '_blank');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Spin spinning={loading}>
|
||||||
title={
|
{keywords && keywords.length > 0 && (
|
||||||
<span>
|
<div style={{ position: 'relative' }}>
|
||||||
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
|
<Space
|
||||||
热门概念
|
size={[6, 6]}
|
||||||
</span>
|
wrap
|
||||||
}
|
style={{
|
||||||
className="popular-keywords"
|
alignItems: 'center',
|
||||||
style={{ marginBottom: 16 }}
|
maxHeight: '62px', // 约两行的高度 (每行约28-30px)
|
||||||
extra={
|
overflow: 'hidden',
|
||||||
<span style={{ fontSize: 12, color: '#999' }}>
|
paddingRight: '90px' // 为右侧按钮留出空间
|
||||||
涨幅TOP20
|
}}
|
||||||
</span>
|
>
|
||||||
}
|
{/* 标题 */}
|
||||||
>
|
<span style={{
|
||||||
<Spin spinning={loading}>
|
color: '#ff4d4f',
|
||||||
{keywords && keywords.length > 0 ? (
|
fontSize: 13,
|
||||||
<>
|
fontWeight: 500,
|
||||||
<Space size={[8, 8]} wrap style={{ marginBottom: 16 }}>
|
marginRight: 4
|
||||||
{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'
|
|
||||||
}}>
|
}}>
|
||||||
<Button
|
热门概念:
|
||||||
type="link"
|
</span>
|
||||||
onClick={handleViewMore}
|
|
||||||
|
{/* 所有标签 */}
|
||||||
|
{keywords.map((item) => (
|
||||||
|
<Tag
|
||||||
|
key={item.concept_id}
|
||||||
|
color={getTagColor(item.change_pct)}
|
||||||
style={{
|
style={{
|
||||||
color: '#1890ff',
|
cursor: 'pointer',
|
||||||
fontWeight: 500
|
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';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
查看更多概念
|
<span>{item.keyword}</span>
|
||||||
<RightOutlined style={{ fontSize: 12, marginLeft: 4 }} />
|
<span style={{
|
||||||
</Button>
|
marginLeft: 4,
|
||||||
</div>
|
fontWeight: 'bold'
|
||||||
</>
|
}}>
|
||||||
) : (
|
{formatChangePct(item.change_pct)}
|
||||||
<Empty
|
</span>
|
||||||
description="暂无热门概念"
|
<span style={{
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
marginLeft: 3,
|
||||||
/>
|
fontSize: 10,
|
||||||
)}
|
opacity: 0.75
|
||||||
</Spin>
|
}}>
|
||||||
</Card>
|
({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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user