- SubTabContainer 内部为每个 Tab 添加 Suspense fallback={null}
- 移除 Company/index.tsx 外层 Suspense 和 TabLoadingFallback
- 切换一级 Tab 时不再显示整体 loading,直接展示内容
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
516 lines
16 KiB
JavaScript
516 lines
16 KiB
JavaScript
// src/services/marketService.js
|
||
/**
|
||
* 完整的市场行情数据服务层
|
||
* 对应Flask后端的所有市场API接口
|
||
*/
|
||
|
||
import axios from '@utils/axiosConfig';
|
||
import { logger } from '../utils/logger';
|
||
|
||
/**
|
||
* 统一的 API 请求函数
|
||
* axios 拦截器已自动处理日志记录
|
||
*/
|
||
const apiRequest = async (url, options = {}) => {
|
||
const { method = 'GET', body, ...rest } = options;
|
||
|
||
const config = {
|
||
method,
|
||
url,
|
||
...rest,
|
||
};
|
||
|
||
// 如果有 body,根据方法设置 data 或 params
|
||
if (body) {
|
||
if (method === 'GET') {
|
||
config.params = typeof body === 'string' ? JSON.parse(body) : body;
|
||
} else {
|
||
config.data = typeof body === 'string' ? JSON.parse(body) : body;
|
||
}
|
||
}
|
||
|
||
const response = await axios(config);
|
||
return response.data;
|
||
};
|
||
|
||
export const marketService = {
|
||
/**
|
||
* 获取股票交易数据(日K线)
|
||
* @param {string} seccode - 股票代码
|
||
* @param {number} days - 获取天数
|
||
* @param {string} end_date - 截止日期
|
||
*/
|
||
getTradeData: async (seccode, days = 60, end_date = null) => {
|
||
let url = `/api/market/trade/${seccode}?days=${days}`;
|
||
if (end_date) {
|
||
url += `&end_date=${end_date}`;
|
||
}
|
||
return await apiRequest(url);
|
||
},
|
||
|
||
/**
|
||
* 批量获取多只股票的交易数据(日K线)
|
||
* @param {string[]} codes - 股票代码数组(6位代码)
|
||
* @param {number} days - 获取天数,默认1
|
||
* @param {string} end_date - 截止日期
|
||
* @returns {Promise<{success: boolean, data: Object}>}
|
||
*/
|
||
getBatchTradeData: async (codes, days = 1, end_date = null) => {
|
||
const body = { codes, days };
|
||
if (end_date) {
|
||
body.end_date = end_date;
|
||
}
|
||
return await apiRequest('/api/market/trade/batch', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body)
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 获取融资融券数据
|
||
* @param {string} seccode - 股票代码
|
||
* @param {number} days - 获取天数
|
||
*/
|
||
getFundingData: async (seccode, days = 30) => {
|
||
return await apiRequest(`/api/market/funding/${seccode}?days=${days}`);
|
||
},
|
||
|
||
/**
|
||
* 获取大宗交易数据
|
||
* @param {string} seccode - 股票代码
|
||
* @param {number} days - 获取天数
|
||
*/
|
||
getBigDealData: async (seccode, days = 30) => {
|
||
return await apiRequest(`/api/market/bigdeal/${seccode}?days=${days}`);
|
||
},
|
||
|
||
/**
|
||
* 获取龙虎榜数据
|
||
* @param {string} seccode - 股票代码
|
||
* @param {number} days - 获取天数
|
||
*/
|
||
getUnusualData: async (seccode, days = 30) => {
|
||
return await apiRequest(`/api/market/unusual/${seccode}?days=${days}`);
|
||
},
|
||
|
||
/**
|
||
* 获取股权质押数据
|
||
* @param {string} seccode - 股票代码
|
||
*/
|
||
getPledgeData: async (seccode) => {
|
||
return await apiRequest(`/api/market/pledge/${seccode}`);
|
||
},
|
||
|
||
/**
|
||
* 获取市场汇总数据
|
||
* @param {string} seccode - 股票代码
|
||
*/
|
||
getMarketSummary: async (seccode) => {
|
||
return await apiRequest(`/api/market/summary/${seccode}`);
|
||
},
|
||
|
||
/**
|
||
* 批量获取多只股票数据
|
||
* @param {array} seccodes - 股票代码数组
|
||
*/
|
||
getBatchMarketData: async (seccodes) => {
|
||
const promises = seccodes.map(code => marketService.getMarketSummary(code));
|
||
const results = await Promise.allSettled(promises);
|
||
|
||
return results.map((result, index) => {
|
||
if (result.status === 'fulfilled' && result.value.success) {
|
||
return result.value.data;
|
||
}
|
||
return {
|
||
stock_code: seccodes[index],
|
||
error: true,
|
||
message: result.reason?.message || 'Failed to fetch data'
|
||
};
|
||
});
|
||
},
|
||
};
|
||
|
||
// 数据格式化工具函数(与financialService保持一致的风格)
|
||
export const marketFormatUtils = {
|
||
/**
|
||
* 格式化大数字(亿/万)
|
||
* @param {number} num - 数字
|
||
* @param {number} decimal - 小数位数
|
||
*/
|
||
formatLargeNumber: (num, decimal = 2) => {
|
||
if (num === null || num === undefined) return '-';
|
||
|
||
const absNum = Math.abs(num);
|
||
let result = '';
|
||
|
||
if (absNum >= 100000000) {
|
||
result = (num / 100000000).toFixed(decimal) + '亿';
|
||
} else if (absNum >= 10000) {
|
||
result = (num / 10000).toFixed(decimal) + '万';
|
||
} else if (absNum >= 1) {
|
||
result = num.toFixed(decimal);
|
||
} else {
|
||
result = num.toFixed(4);
|
||
}
|
||
|
||
return result;
|
||
},
|
||
|
||
/**
|
||
* 格式化百分比
|
||
* @param {number} num - 数字
|
||
* @param {number} decimal - 小数位数
|
||
*/
|
||
formatPercent: (num, decimal = 2) => {
|
||
if (num === null || num === undefined) return '-';
|
||
return num.toFixed(decimal) + '%';
|
||
},
|
||
|
||
/**
|
||
* 格式化日期
|
||
* @param {string} dateStr - 日期字符串
|
||
*/
|
||
formatDate: (dateStr) => {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
},
|
||
|
||
/**
|
||
* 格式化成交量
|
||
* @param {number} volume - 成交量
|
||
*/
|
||
formatVolume: (volume) => {
|
||
if (!volume) return '-';
|
||
if (volume >= 100000000) {
|
||
return (volume / 100000000).toFixed(2) + '亿股';
|
||
} else if (volume >= 10000) {
|
||
return (volume / 10000).toFixed(2) + '万股';
|
||
}
|
||
return volume.toFixed(0) + '股';
|
||
},
|
||
|
||
/**
|
||
* 格式化成交额
|
||
* @param {number} amount - 成交额
|
||
*/
|
||
formatAmount: (amount) => {
|
||
if (!amount) return '-';
|
||
return '¥' + marketFormatUtils.formatLargeNumber(amount);
|
||
},
|
||
|
||
/**
|
||
* 获取涨跌颜色(中国市场:红涨绿跌)
|
||
* @param {number} value - 涨跌值
|
||
*/
|
||
getPriceColor: (value) => {
|
||
if (value === null || value === undefined) return 'gray.500';
|
||
if (value > 0) return 'red.500';
|
||
if (value < 0) return 'green.500';
|
||
return 'gray.500';
|
||
},
|
||
|
||
/**
|
||
* 格式化涨跌幅
|
||
* @param {number} change - 涨跌幅
|
||
*/
|
||
formatChange: (change) => {
|
||
if (change === null || change === undefined) return '-';
|
||
const formatted = change.toFixed(2) + '%';
|
||
return change >= 0 ? '+' + formatted : formatted;
|
||
},
|
||
|
||
/**
|
||
* 格式化换手率
|
||
* @param {number} turnover - 换手率
|
||
*/
|
||
formatTurnover: (turnover) => {
|
||
if (!turnover) return '-';
|
||
return turnover.toFixed(2) + '%';
|
||
},
|
||
|
||
/**
|
||
* 获取融资融券状态
|
||
* @param {object} funding - 融资融券数据
|
||
*/
|
||
getFundingStatus: (funding) => {
|
||
if (!funding) return { status: 'unknown', label: '未知' };
|
||
|
||
const netFinancing = funding.financing.buy - funding.financing.repay;
|
||
const netSecurities = funding.securities.sell - funding.securities.repay;
|
||
|
||
if (netFinancing > 0 && netSecurities <= 0) {
|
||
return { status: 'bullish', label: '看多', color: 'red.500' };
|
||
} else if (netFinancing <= 0 && netSecurities > 0) {
|
||
return { status: 'bearish', label: '看空', color: 'green.500' };
|
||
} else if (netFinancing > 0 && netSecurities > 0) {
|
||
return { status: 'divergent', label: '分歧', color: 'yellow.500' };
|
||
} else {
|
||
return { status: 'neutral', label: '中性', color: 'gray.500' };
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 计算技术指标
|
||
* @param {array} data - K线数据
|
||
*/
|
||
calculateTechnicalIndicators: (data) => {
|
||
// 计算MA均线
|
||
const calculateMA = (period) => {
|
||
return data.map((item, index) => {
|
||
if (index < period - 1) return null;
|
||
const sum = data.slice(index - period + 1, index + 1)
|
||
.reduce((acc, cur) => acc + cur.close, 0);
|
||
return sum / period;
|
||
});
|
||
};
|
||
|
||
// 计算RSI
|
||
const calculateRSI = (period = 14) => {
|
||
const changes = data.map((item, index) => {
|
||
if (index === 0) return null;
|
||
return item.close - data[index - 1].close;
|
||
});
|
||
|
||
let avgGain = 0;
|
||
let avgLoss = 0;
|
||
|
||
for (let i = 1; i <= period; i++) {
|
||
if (changes[i] > 0) avgGain += changes[i];
|
||
else avgLoss += Math.abs(changes[i]);
|
||
}
|
||
|
||
avgGain /= period;
|
||
avgLoss /= period;
|
||
|
||
const rsi = [null];
|
||
for (let i = 1; i < period; i++) {
|
||
rsi.push(null);
|
||
}
|
||
|
||
for (let i = period; i < data.length; i++) {
|
||
const change = changes[i];
|
||
if (change > 0) {
|
||
avgGain = (avgGain * (period - 1) + change) / period;
|
||
avgLoss = (avgLoss * (period - 1)) / period;
|
||
} else {
|
||
avgGain = (avgGain * (period - 1)) / period;
|
||
avgLoss = (avgLoss * (period - 1) + Math.abs(change)) / period;
|
||
}
|
||
|
||
const rs = avgGain / avgLoss;
|
||
rsi.push(100 - (100 / (1 + rs)));
|
||
}
|
||
|
||
return rsi;
|
||
};
|
||
|
||
return {
|
||
ma5: calculateMA(5),
|
||
ma10: calculateMA(10),
|
||
ma20: calculateMA(20),
|
||
ma60: calculateMA(60),
|
||
rsi: calculateRSI(14)
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 获取大宗交易评级
|
||
* @param {number} amount - 交易金额
|
||
* @param {number} avgAmount - 平均交易金额
|
||
*/
|
||
getBigDealRating: (amount, avgAmount) => {
|
||
if (!amount || !avgAmount) return { level: 'normal', label: '普通' };
|
||
|
||
const ratio = amount / avgAmount;
|
||
if (ratio >= 3) return { level: 'huge', label: '超大', color: 'red.600' };
|
||
if (ratio >= 2) return { level: 'large', label: '大额', color: 'orange.500' };
|
||
if (ratio >= 1.5) return { level: 'medium', label: '中等', color: 'yellow.500' };
|
||
return { level: 'normal', label: '普通', color: 'gray.500' };
|
||
},
|
||
};
|
||
|
||
// 图表数据处理工具
|
||
export const marketChartUtils = {
|
||
/**
|
||
* 准备K线图数据
|
||
* @param {array} data - 原始交易数据
|
||
*/
|
||
prepareKLineData: (data) => {
|
||
return data.map(item => ({
|
||
date: item.date.substring(5, 10), // MM-DD格式
|
||
fullDate: item.date,
|
||
open: item.open,
|
||
high: item.high,
|
||
low: item.low,
|
||
close: item.close,
|
||
volume: item.volume,
|
||
amount: item.amount,
|
||
change: item.change_percent,
|
||
k: [item.open, item.close, item.low, item.high], // K线数据格式
|
||
color: item.change_percent >= 0 ? '#ef4444' : '#10b981' // 红涨绿跌
|
||
}));
|
||
},
|
||
|
||
/**
|
||
* 准备融资融券趋势图数据
|
||
* @param {array} data - 原始融资融券数据
|
||
*/
|
||
prepareFundingTrendData: (data) => {
|
||
return data.map(item => ({
|
||
date: item.date.substring(5, 10),
|
||
fullDate: item.date,
|
||
financing: item.financing.balance / 100000000, // 转换为亿
|
||
securities: item.securities.balance_amount / 100000000,
|
||
total: item.total_balance / 100000000,
|
||
netBuy: (item.financing.buy - item.financing.repay) / 10000, // 转换为万
|
||
netSell: (item.securities.sell - item.securities.repay) / 10000
|
||
}));
|
||
},
|
||
|
||
/**
|
||
* 准备龙虎榜资金流向图数据
|
||
* @param {array} data - 龙虎榜数据
|
||
*/
|
||
prepareUnusualFlowData: (data) => {
|
||
const flowData = {};
|
||
|
||
data.grouped_data.forEach(dayData => {
|
||
flowData[dayData.date] = {
|
||
date: dayData.date,
|
||
totalBuy: dayData.total_buy / 10000, // 转换为万
|
||
totalSell: dayData.total_sell / 10000,
|
||
netFlow: dayData.net_amount / 10000,
|
||
buyersCount: dayData.buyers.length,
|
||
sellersCount: dayData.sellers.length
|
||
};
|
||
});
|
||
|
||
return Object.values(flowData).sort((a, b) =>
|
||
new Date(a.date) - new Date(b.date)
|
||
);
|
||
},
|
||
|
||
/**
|
||
* 准备股权质押趋势图数据
|
||
* @param {array} data - 原始质押数据
|
||
*/
|
||
preparePledgeTrendData: (data) => {
|
||
return data.map(item => ({
|
||
date: item.end_date.substring(0, 10),
|
||
pledgeRatio: item.pledge_ratio,
|
||
pledgeCount: item.pledge_count,
|
||
totalPledge: item.total_pledge / 10000, // 转换为万股
|
||
unrestricted: item.unrestricted_pledge / 10000,
|
||
restricted: item.restricted_pledge / 10000
|
||
})).reverse(); // 按时间正序排列
|
||
},
|
||
|
||
/**
|
||
* 获取图表配色方案
|
||
* @param {string} theme - 主题名称
|
||
*/
|
||
getChartTheme: (theme = 'default') => {
|
||
const themes = {
|
||
default: {
|
||
upColor: '#ef4444', // 红色上涨
|
||
downColor: '#10b981', // 绿色下跌
|
||
volumeUpColor: 'rgba(239, 68, 68, 0.7)',
|
||
volumeDownColor: 'rgba(16, 185, 129, 0.7)',
|
||
ma5Color: '#3b82f6',
|
||
ma10Color: '#8b5cf6',
|
||
ma20Color: '#f59e0b',
|
||
ma60Color: '#ec4899',
|
||
gridColor: '#e5e7eb'
|
||
},
|
||
dark: {
|
||
upColor: '#ef4444',
|
||
downColor: '#10b981',
|
||
volumeUpColor: 'rgba(239, 68, 68, 0.5)',
|
||
volumeDownColor: 'rgba(16, 185, 129, 0.5)',
|
||
ma5Color: '#60a5fa',
|
||
ma10Color: '#a78bfa',
|
||
ma20Color: '#fbbf24',
|
||
ma60Color: '#f472b6',
|
||
gridColor: '#374151'
|
||
}
|
||
};
|
||
|
||
return themes[theme] || themes.default;
|
||
},
|
||
};
|
||
|
||
// 数据缓存管理
|
||
export const marketCacheManager = {
|
||
cache: new Map(),
|
||
|
||
/**
|
||
* 生成缓存键
|
||
* @param {string} stockCode - 股票代码
|
||
* @param {string} dataType - 数据类型
|
||
* @param {object} params - 查询参数
|
||
*/
|
||
generateKey: (stockCode, dataType, params = {}) => {
|
||
return `market_${stockCode}_${dataType}_${JSON.stringify(params)}`;
|
||
},
|
||
|
||
/**
|
||
* 获取缓存数据
|
||
* @param {string} key - 缓存键
|
||
* @param {number} maxAge - 最大缓存时间(毫秒)
|
||
*/
|
||
get: function(key, maxAge = 5 * 60 * 1000) {
|
||
const cached = this.cache.get(key);
|
||
if (cached) {
|
||
const { data, timestamp } = cached;
|
||
if (Date.now() - timestamp < maxAge) {
|
||
logger.debug('marketCacheManager', '缓存命中', { key });
|
||
return data;
|
||
}
|
||
this.cache.delete(key);
|
||
}
|
||
return null;
|
||
},
|
||
|
||
/**
|
||
* 设置缓存数据
|
||
* @param {string} key - 缓存键
|
||
* @param {any} data - 要缓存的数据
|
||
*/
|
||
set: function(key, data) {
|
||
this.cache.set(key, {
|
||
data,
|
||
timestamp: Date.now()
|
||
});
|
||
logger.debug('marketCacheManager', '数据已缓存', { key });
|
||
},
|
||
|
||
/**
|
||
* 清除所有缓存
|
||
*/
|
||
clear: function() {
|
||
const cacheSize = this.cache.size;
|
||
this.cache.clear();
|
||
logger.debug('marketCacheManager', '清除所有缓存', { clearedCount: cacheSize });
|
||
},
|
||
|
||
/**
|
||
* 清除特定股票的缓存
|
||
* @param {string} stockCode - 股票代码
|
||
*/
|
||
clearStock: function(stockCode) {
|
||
let clearedCount = 0;
|
||
for (const key of this.cache.keys()) {
|
||
if (key.includes(`market_${stockCode}_`)) {
|
||
this.cache.delete(key);
|
||
clearedCount++;
|
||
}
|
||
}
|
||
logger.debug('marketCacheManager', '清除股票缓存', {
|
||
stockCode,
|
||
clearedCount
|
||
});
|
||
}
|
||
};
|
||
|
||
export default marketService; |