refactor: 股票数据管理迁移到 Redux,新增类型化 Hooks
- stockSlice: 新增 loadAllStocks action(带缓存检查)
- stockSlice: watchlist 结构升级为 { stock_code, stock_name }[]
- store/hooks.ts: 新增 useAppDispatch, useAppSelector 类型化 hooks
- stockService: 移除 getAllStocks(已迁移到 Redux)
- mock: 股票搜索支持模糊匹配 + 相关性排序
This commit is contained in:
@@ -123,12 +123,12 @@ const generateStockList = () => {
|
|||||||
|
|
||||||
// 股票相关的 Handlers
|
// 股票相关的 Handlers
|
||||||
export const stockHandlers = [
|
export const stockHandlers = [
|
||||||
// 搜索股票(个股中心页面使用)
|
// 搜索股票(个股中心页面使用)- 支持模糊搜索
|
||||||
http.get('/api/stocks/search', async ({ request }) => {
|
http.get('/api/stocks/search', async ({ request }) => {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const query = url.searchParams.get('q') || '';
|
const query = (url.searchParams.get('q') || '').toLowerCase().trim();
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||||
|
|
||||||
console.log('[Mock Stock] 搜索股票:', { query, limit });
|
console.log('[Mock Stock] 搜索股票:', { query, limit });
|
||||||
@@ -136,22 +136,44 @@ export const stockHandlers = [
|
|||||||
const stocks = generateStockList();
|
const stocks = generateStockList();
|
||||||
|
|
||||||
// 如果没有搜索词,返回空结果
|
// 如果没有搜索词,返回空结果
|
||||||
if (!query.trim()) {
|
if (!query) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: []
|
data: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤匹配的股票
|
// 模糊搜索:代码 + 名称(不区分大小写)
|
||||||
const results = stocks.filter(s =>
|
const results = stocks.filter(s => {
|
||||||
s.code.includes(query) || s.name.includes(query)
|
const code = s.code.toLowerCase();
|
||||||
).slice(0, limit);
|
const name = s.name.toLowerCase();
|
||||||
|
return code.includes(query) || name.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按相关性排序:完全匹配 > 开头匹配 > 包含匹配
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const aCode = a.code.toLowerCase();
|
||||||
|
const bCode = b.code.toLowerCase();
|
||||||
|
const aName = a.name.toLowerCase();
|
||||||
|
const bName = b.name.toLowerCase();
|
||||||
|
|
||||||
|
// 计算匹配分数
|
||||||
|
const getScore = (code, name) => {
|
||||||
|
if (code === query || name === query) return 100; // 完全匹配
|
||||||
|
if (code.startsWith(query)) return 80; // 代码开头
|
||||||
|
if (name.startsWith(query)) return 60; // 名称开头
|
||||||
|
if (code.includes(query)) return 40; // 代码包含
|
||||||
|
if (name.includes(query)) return 20; // 名称包含
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return getScore(bCode, bName) - getScore(aCode, aName);
|
||||||
|
});
|
||||||
|
|
||||||
// 返回格式化数据
|
// 返回格式化数据
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: results.map(s => ({
|
data: results.slice(0, limit).map(s => ({
|
||||||
stock_code: s.code,
|
stock_code: s.code,
|
||||||
stock_name: s.name,
|
stock_name: s.name,
|
||||||
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
||||||
|
|||||||
@@ -1,52 +1,11 @@
|
|||||||
// src/services/stockService.js
|
// src/services/stockService.js
|
||||||
// 股票数据服务
|
// 股票数据服务 - 模糊搜索工具函数
|
||||||
|
// 注意: getAllStocks 已迁移到 Redux (stockSlice.loadAllStocks)
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票数据服务
|
* 股票数据服务
|
||||||
*/
|
*/
|
||||||
export const stockService = {
|
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)
|
* 模糊搜索股票(匹配 code 或 name)
|
||||||
* @param {string} query - 搜索关键词
|
* @param {string} query - 搜索关键词
|
||||||
|
|||||||
15
src/store/hooks.ts
Normal file
15
src/store/hooks.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Redux Typed Hooks
|
||||||
|
* 提供类型安全的 useDispatch 和 useSelector hooks
|
||||||
|
*/
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import type { TypedUseSelectorHook } from 'react-redux';
|
||||||
|
import { store } from './index';
|
||||||
|
|
||||||
|
// 从 store 推断类型
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|
||||||
|
// 类型化的 hooks
|
||||||
|
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||||
@@ -63,4 +63,9 @@ export const injectReducer = (key, reducer) => {
|
|||||||
store.replaceReducer(createRootReducer());
|
store.replaceReducer(createRootReducer());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {typeof store.dispatch} AppDispatch
|
||||||
|
* @typedef {ReturnType<typeof store.getState>} RootState
|
||||||
|
*/
|
||||||
|
|
||||||
export default store;
|
export default store;
|
||||||
|
|||||||
@@ -152,11 +152,11 @@ export const fetchExpectationScore = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载用户自选股列表
|
* 加载用户自选股列表(包含完整信息)
|
||||||
*/
|
*/
|
||||||
export const loadWatchlist = createAsyncThunk(
|
export const loadWatchlist = createAsyncThunk(
|
||||||
'stock/loadWatchlist',
|
'stock/loadWatchlist',
|
||||||
async (_, { getState }) => {
|
async () => {
|
||||||
logger.debug('stockSlice', 'loadWatchlist');
|
logger.debug('stockSlice', 'loadWatchlist');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -167,11 +167,15 @@ export const loadWatchlist = createAsyncThunk(
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
const stockCodes = data.data.map(item => item.stock_code);
|
// 返回完整的股票信息,而不仅仅是 stock_code
|
||||||
|
const watchlistData = data.data.map(item => ({
|
||||||
|
stock_code: item.stock_code,
|
||||||
|
stock_name: item.stock_name,
|
||||||
|
}));
|
||||||
logger.debug('stockSlice', '自选股列表加载成功', {
|
logger.debug('stockSlice', '自选股列表加载成功', {
|
||||||
count: stockCodes.length
|
count: watchlistData.length
|
||||||
});
|
});
|
||||||
return stockCodes;
|
return watchlistData;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -182,6 +186,43 @@ export const loadWatchlist = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载全部股票列表(用于前端模糊搜索)
|
||||||
|
*/
|
||||||
|
export const loadAllStocks = createAsyncThunk(
|
||||||
|
'stock/loadAllStocks',
|
||||||
|
async (_, { getState }) => {
|
||||||
|
// 检查缓存
|
||||||
|
const cached = getState().stock.allStocks;
|
||||||
|
if (cached && cached.length > 0) {
|
||||||
|
logger.debug('stockSlice', 'allStocks 缓存命中', { count: cached.length });
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('stockSlice', 'loadAllStocks');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiBase = getApiBase();
|
||||||
|
const response = await fetch(`${apiBase}/api/stocklist`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
logger.debug('stockSlice', '全部股票列表加载成功', {
|
||||||
|
count: data.length
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('stockSlice', 'loadAllStocks', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换自选股状态
|
* 切换自选股状态
|
||||||
*/
|
*/
|
||||||
@@ -219,7 +260,7 @@ export const toggleWatchlist = createAsyncThunk(
|
|||||||
throw new Error(data.error || '操作失败');
|
throw new Error(data.error || '操作失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stockCode, isInWatchlist };
|
return { stockCode, stockName, isInWatchlist };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -246,9 +287,12 @@ const stockSlice = createSlice({
|
|||||||
// 超预期得分缓存 { [eventId]: score }
|
// 超预期得分缓存 { [eventId]: score }
|
||||||
expectationScores: {},
|
expectationScores: {},
|
||||||
|
|
||||||
// 自选股列表 Set<stockCode>
|
// 自选股列表 [{ stock_code, stock_name }]
|
||||||
watchlist: [],
|
watchlist: [],
|
||||||
|
|
||||||
|
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
|
||||||
|
allStocks: [],
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
loading: {
|
loading: {
|
||||||
stocks: false,
|
stocks: false,
|
||||||
@@ -256,7 +300,8 @@ const stockSlice = createSlice({
|
|||||||
eventDetail: false,
|
eventDetail: false,
|
||||||
historicalEvents: false,
|
historicalEvents: false,
|
||||||
chainAnalysis: false,
|
chainAnalysis: false,
|
||||||
watchlist: false
|
watchlist: false,
|
||||||
|
allStocks: false
|
||||||
},
|
},
|
||||||
|
|
||||||
// 错误信息
|
// 错误信息
|
||||||
@@ -383,16 +428,29 @@ const stockSlice = createSlice({
|
|||||||
state.loading.watchlist = false;
|
state.loading.watchlist = false;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== loadAllStocks =====
|
||||||
|
.addCase(loadAllStocks.pending, (state) => {
|
||||||
|
state.loading.allStocks = true;
|
||||||
|
})
|
||||||
|
.addCase(loadAllStocks.fulfilled, (state, action) => {
|
||||||
|
state.allStocks = action.payload;
|
||||||
|
state.loading.allStocks = false;
|
||||||
|
})
|
||||||
|
.addCase(loadAllStocks.rejected, (state) => {
|
||||||
|
state.loading.allStocks = false;
|
||||||
|
})
|
||||||
|
|
||||||
// ===== toggleWatchlist =====
|
// ===== toggleWatchlist =====
|
||||||
.addCase(toggleWatchlist.fulfilled, (state, action) => {
|
.addCase(toggleWatchlist.fulfilled, (state, action) => {
|
||||||
const { stockCode, isInWatchlist } = action.payload;
|
const { stockCode, stockName, isInWatchlist } = action.payload;
|
||||||
if (isInWatchlist) {
|
if (isInWatchlist) {
|
||||||
// 移除
|
// 移除
|
||||||
state.watchlist = state.watchlist.filter(code => code !== stockCode);
|
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
|
||||||
} else {
|
} else {
|
||||||
// 添加
|
// 添加
|
||||||
if (!state.watchlist.includes(stockCode)) {
|
const exists = state.watchlist.some(item => item.stock_code === stockCode);
|
||||||
state.watchlist.push(stockCode);
|
if (!exists) {
|
||||||
|
state.watchlist.push({ stock_code: stockCode, stock_name: stockName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user