更新Company页面的UI为FUI风格

This commit is contained in:
2025-12-17 23:38:46 +08:00
parent 6cb2742cf6
commit a2f224d118
2 changed files with 501 additions and 17 deletions

View File

@@ -21,8 +21,8 @@ import {
MenuDivider,
Tooltip,
} from '@chakra-ui/react';
import { RepeatIcon, InfoIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { BarChart2, Clock, TrendingUp, Calendar, LineChart, Activity } from 'lucide-react';
import { RepeatIcon, InfoIcon, ChevronDownIcon, ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import { BarChart2, Clock, TrendingUp, Calendar, LineChart, Activity, Pencil } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { darkGoldTheme, PERIOD_OPTIONS } from '../../../constants';
@@ -31,6 +31,7 @@ import {
getMinuteKLineDarkGoldOption,
type IndicatorType,
type MainIndicatorType,
type DrawingType,
} from '../../../utils/chartOptions';
import type { KLineModuleProps } from '../../../types';
@@ -57,6 +58,9 @@ const SUB_INDICATOR_OPTIONS: { value: IndicatorType; label: string; description:
{ value: 'MACD', label: 'MACD', description: '平滑异同移动平均线' },
{ value: 'KDJ', label: 'KDJ', description: '随机指标' },
{ value: 'RSI', label: 'RSI', description: '相对强弱指标' },
{ value: 'WR', label: 'WR', description: '威廉指标(超买超卖)' },
{ value: 'CCI', label: 'CCI', description: '商品通道指标' },
{ value: 'BIAS', label: 'BIAS', description: '乖离率' },
{ value: 'VOL', label: '仅成交量', description: '不显示副图指标' },
];
@@ -67,6 +71,14 @@ const MAIN_INDICATOR_OPTIONS: { value: MainIndicatorType; label: string; descrip
{ value: 'NONE', label: '无', description: '不显示主图指标' },
];
// 绘图工具选项
const DRAWING_OPTIONS: { value: DrawingType; label: string; description: string }[] = [
{ value: 'NONE', label: '无', description: '不显示绘图工具' },
{ value: 'SUPPORT_RESISTANCE', label: '支撑/阻力', description: '自动识别支撑位和阻力位' },
{ value: 'TREND_LINE', label: '趋势线', description: '基于线性回归的趋势线' },
{ value: 'ALL', label: '全部显示', description: '显示所有参考线' },
];
const KLineModule: React.FC<KLineModuleProps> = ({
theme,
tradeData,
@@ -81,6 +93,8 @@ const KLineModule: React.FC<KLineModuleProps> = ({
const [mode, setMode] = useState<ChartMode>('daily');
const [subIndicator, setSubIndicator] = useState<IndicatorType>('MACD');
const [mainIndicator, setMainIndicator] = useState<MainIndicatorType>('MA');
const [showAnalysis, setShowAnalysis] = useState<boolean>(true);
const [drawingType, setDrawingType] = useState<DrawingType>('NONE');
const hasMinuteData = minuteData && minuteData.data && minuteData.data.length > 0;
// 切换到分时模式时自动加载数据
@@ -188,6 +202,20 @@ const KLineModule: React.FC<KLineModuleProps> = ({
</HStack>
)}
{/* 隐藏/显示涨幅分析 */}
<Tooltip label={showAnalysis ? '隐藏涨幅分析标记' : '显示涨幅分析标记'} placement="top" hasArrow>
<Button
size="sm"
variant="outline"
leftIcon={showAnalysis ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowAnalysis(!showAnalysis)}
{...(showAnalysis ? inactiveButtonStyle : activeButtonStyle)}
minW="90px"
>
{showAnalysis ? '隐藏分析' : '显示分析'}
</Button>
</Tooltip>
{/* 主图指标选择 */}
<Menu>
<Tooltip label="主图指标" placement="top" hasArrow>
@@ -268,6 +296,47 @@ const KLineModule: React.FC<KLineModuleProps> = ({
))}
</MenuList>
</Menu>
{/* 绘图工具选择 */}
<Menu>
<Tooltip label="绘图工具" placement="top" hasArrow>
<MenuButton
as={Button}
size="sm"
variant="outline"
rightIcon={<ChevronDownIcon />}
leftIcon={<Pencil size={14} />}
{...(drawingType !== 'NONE' ? activeButtonStyle : inactiveButtonStyle)}
minW="90px"
>
{DRAWING_OPTIONS.find(o => o.value === drawingType)?.label || '绘图'}
</MenuButton>
</Tooltip>
<MenuList
bg="#1a1a2e"
borderColor={darkGoldTheme.border}
boxShadow="0 4px 20px rgba(0,0,0,0.5)"
>
{DRAWING_OPTIONS.map((option) => (
<MenuItem
key={option.value}
onClick={() => setDrawingType(option.value)}
bg={drawingType === option.value ? 'rgba(212, 175, 55, 0.2)' : 'transparent'}
color={drawingType === option.value ? darkGoldTheme.gold : darkGoldTheme.textPrimary}
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
>
<VStack align="start" spacing={0}>
<Text fontSize="sm" fontWeight={drawingType === option.value ? 'bold' : 'normal'}>
{option.label}
</Text>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{option.description}
</Text>
</VStack>
</MenuItem>
))}
</MenuList>
</Menu>
</>
)}
@@ -314,7 +383,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
tradeData.length > 0 ? (
<Box h="650px">
<ReactECharts
option={getKLineDarkGoldOption(tradeData, analysisMap, subIndicator, mainIndicator)}
option={getKLineDarkGoldOption(tradeData, analysisMap, subIndicator, mainIndicator, showAnalysis, drawingType)}
style={{ height: '100%', width: '100%' }}
theme="dark"
onEvents={{ click: onChartClick }}

View File

@@ -250,6 +250,133 @@ export const calculateBOLL = (
return { upper, middle, lower };
};
/**
* 计算WR (威廉指标)
* @param highPrices 最高价数组
* @param lowPrices 最低价数组
* @param closePrices 收盘价数组
* @param period 周期 (默认14)
*/
export const calculateWR = (
highPrices: number[],
lowPrices: number[],
closePrices: number[],
period = 14
): (number | null)[] => {
const result: (number | null)[] = [];
for (let i = 0; i < closePrices.length; i++) {
if (i < period - 1) {
result.push(null);
continue;
}
let highestHigh = -Infinity;
let lowestLow = Infinity;
for (let j = 0; j < period; j++) {
highestHigh = Math.max(highestHigh, highPrices[i - j]);
lowestLow = Math.min(lowestLow, lowPrices[i - j]);
}
if (highestHigh === lowestLow) {
result.push(0);
} else {
// WR = (最高价 - 收盘价) / (最高价 - 最低价) * -100
result.push(((highestHigh - closePrices[i]) / (highestHigh - lowestLow)) * -100);
}
}
return result;
};
/**
* 计算CCI (商品通道指标)
* @param highPrices 最高价数组
* @param lowPrices 最低价数组
* @param closePrices 收盘价数组
* @param period 周期 (默认14)
*/
export const calculateCCI = (
highPrices: number[],
lowPrices: number[],
closePrices: number[],
period = 14
): (number | null)[] => {
const result: (number | null)[] = [];
// 计算典型价格 (TP = (High + Low + Close) / 3)
const tp = closePrices.map((close, i) => (highPrices[i] + lowPrices[i] + close) / 3);
for (let i = 0; i < closePrices.length; i++) {
if (i < period - 1) {
result.push(null);
continue;
}
// 计算TP的移动平均
let tpSum = 0;
for (let j = 0; j < period; j++) {
tpSum += tp[i - j];
}
const tpMA = tpSum / period;
// 计算平均绝对偏差
let madSum = 0;
for (let j = 0; j < period; j++) {
madSum += Math.abs(tp[i - j] - tpMA);
}
const mad = madSum / period;
// CCI = (TP - MA) / (0.015 * MAD)
if (mad === 0) {
result.push(0);
} else {
result.push((tp[i] - tpMA) / (0.015 * mad));
}
}
return result;
};
/**
* 计算BIAS (乖离率)
* @param closePrices 收盘价数组
* @param period 周期 (默认6)
*/
export const calculateBIAS = (closePrices: number[], period = 6): (number | null)[] => {
const ma = calculateMA(closePrices, period);
const result: (number | null)[] = [];
for (let i = 0; i < closePrices.length; i++) {
if (ma[i] === null || ma[i] === 0) {
result.push(null);
} else {
// BIAS = (收盘价 - MA) / MA * 100
result.push(((closePrices[i] - ma[i]!) / ma[i]!) * 100);
}
}
return result;
};
/**
* 格式化数字最多2位小数去除无意义的尾数
*/
export const formatPrice = (value: number | null | undefined): string => {
if (value === null || value === undefined || isNaN(value)) return '--';
// 使用 toFixed(2) 然后去除尾部的0
const fixed = value.toFixed(2);
return parseFloat(fixed).toString();
};
/**
* 格式化百分比最多2位小数
*/
export const formatPercent = (value: number | null | undefined): string => {
if (value === null || value === undefined || isNaN(value)) return '--';
return parseFloat(value.toFixed(2)).toString();
};
/**
* 生成日K线图配置
*/
@@ -647,23 +774,96 @@ export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null
};
// 技术指标类型
export type IndicatorType = 'VOL' | 'MACD' | 'KDJ' | 'RSI' | 'BOLL' | 'NONE';
export type IndicatorType = 'VOL' | 'MACD' | 'KDJ' | 'RSI' | 'WR' | 'CCI' | 'BIAS' | 'NONE';
// 主图指标类型
export type MainIndicatorType = 'MA' | 'BOLL' | 'NONE';
// 绘图工具类型
export type DrawingType = 'NONE' | 'SUPPORT_RESISTANCE' | 'TREND_LINE' | 'ALL';
/**
* 计算支撑位和阻力位
* 基于近期高低点自动识别
* @param highPrices 最高价数组
* @param lowPrices 最低价数组
* @param period 回看周期默认20日
*/
export const calculateSupportResistance = (
highPrices: number[],
lowPrices: number[],
period = 20
): { support: number | null; resistance: number | null } => {
if (highPrices.length < period) {
return { support: null, resistance: null };
}
// 取最近 period 天的数据
const recentHighs = highPrices.slice(-period);
const recentLows = lowPrices.slice(-period);
// 阻力位:近期最高价
const resistance = Math.max(...recentHighs);
// 支撑位:近期最低价
const support = Math.min(...recentLows);
return { support, resistance };
};
/**
* 计算趋势线数据点
* 使用线性回归拟合趋势
* @param closePrices 收盘价数组
* @param period 回看周期默认60日
*/
export const calculateTrendLine = (
closePrices: number[],
period = 60
): { startPrice: number; endPrice: number; slope: number } | null => {
if (closePrices.length < 5) return null;
// 取最近 period 天的数据(或全部数据如果不足)
const len = Math.min(period, closePrices.length);
const prices = closePrices.slice(-len);
// 使用简单线性回归计算趋势线
// y = mx + b
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
const n = prices.length;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += prices[i];
sumXY += i * prices[i];
sumXX += i * i;
}
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
// 起点和终点价格
const startPrice = intercept;
const endPrice = intercept + slope * (n - 1);
return { startPrice, endPrice, slope };
};
/**
* 生成日K线图配置 - 黑金主题(专业版 - 支持多技术指标)
* @param tradeData K线数据
* @param analysisMap 涨幅分析数据
* @param subIndicator 副图指标 (默认MACD)
* @param mainIndicator 主图指标 (默认MA)
* @param showAnalysis 是否显示涨幅分析标记 (默认true)
* @param drawingType 绘图工具类型 (默认NONE)
*/
export const getKLineDarkGoldOption = (
tradeData: TradeDayData[],
analysisMap: Record<number, RiseAnalysis>,
subIndicator: IndicatorType = 'MACD',
mainIndicator: MainIndicatorType = 'MA'
mainIndicator: MainIndicatorType = 'MA',
showAnalysis: boolean = true,
drawingType: DrawingType = 'NONE'
): EChartsOption => {
if (!tradeData || tradeData.length === 0) return {};
@@ -700,16 +900,24 @@ export const getKLineDarkGoldOption = (
const rsi6 = calculateRSI(closePrices, 6);
const rsi12 = calculateRSI(closePrices, 12);
const rsi24 = calculateRSI(closePrices, 24);
const wr14 = calculateWR(highPrices, lowPrices, closePrices, 14);
const wr6 = calculateWR(highPrices, lowPrices, closePrices, 6);
const cci14 = calculateCCI(highPrices, lowPrices, closePrices, 14);
const bias6 = calculateBIAS(closePrices, 6);
const bias12 = calculateBIAS(closePrices, 12);
const bias24 = calculateBIAS(closePrices, 24);
// 创建涨幅分析标记点
// 创建涨幅分析标记点(仅当 showAnalysis 为 true 时)
const scatterData: [number, number][] = [];
Object.keys(analysisMap).forEach((dateIndex) => {
const idx = parseInt(dateIndex);
if (tradeData[idx]) {
const value = tradeData[idx].high * 1.02;
scatterData.push([idx, value]);
}
});
if (showAnalysis) {
Object.keys(analysisMap).forEach((dateIndex) => {
const idx = parseInt(dateIndex);
if (tradeData[idx]) {
const value = tradeData[idx].high * 1.02;
scatterData.push([idx, value]);
}
});
}
// 根据是否显示副图指标调整布局
const hasSubIndicator = subIndicator !== 'VOL' && subIndicator !== 'NONE';
@@ -834,6 +1042,45 @@ export const getKLineDarkGoldOption = (
legendData.push('BOLL上轨', 'BOLL中轨', 'BOLL下轨');
}
// 计算绘图工具数据
const supportResistance = (drawingType === 'SUPPORT_RESISTANCE' || drawingType === 'ALL')
? calculateSupportResistance(highPrices, lowPrices)
: null;
const trendLine = (drawingType === 'TREND_LINE' || drawingType === 'ALL')
? calculateTrendLine(closePrices)
: null;
// 构建 markLine 数据
const markLineData: any[] = [];
if (supportResistance) {
if (supportResistance.resistance !== null) {
markLineData.push({
name: '阻力位',
yAxis: supportResistance.resistance,
lineStyle: { color: red, type: 'dashed', width: 1.5 },
label: {
formatter: `阻力 ${formatPrice(supportResistance.resistance)}`,
position: 'end',
color: red,
fontSize: 10,
},
});
}
if (supportResistance.support !== null) {
markLineData.push({
name: '支撑位',
yAxis: supportResistance.support,
lineStyle: { color: green, type: 'dashed', width: 1.5 },
label: {
formatter: `支撑 ${formatPrice(supportResistance.support)}`,
position: 'end',
color: green,
fontSize: 10,
},
});
}
}
// 构建系列数据
const series: EChartsOption['series'] = [
{
@@ -846,9 +1093,47 @@ export const getKLineDarkGoldOption = (
borderColor: red,
borderColor0: green,
},
markLine: markLineData.length > 0 ? {
symbol: ['none', 'none'],
data: markLineData,
silent: true,
} : undefined,
},
];
// 添加趋势线(使用单独的 line series
if (trendLine && (drawingType === 'TREND_LINE' || drawingType === 'ALL')) {
const trendLineLen = Math.min(60, closePrices.length);
const trendLineStartIdx = closePrices.length - trendLineLen;
const trendLineData: (number | null)[] = [];
// 前面填充 null
for (let i = 0; i < trendLineStartIdx; i++) {
trendLineData.push(null);
}
// 计算趋势线上每个点的值
const { startPrice, slope } = trendLine;
for (let i = 0; i < trendLineLen; i++) {
trendLineData.push(startPrice + slope * i);
}
series.push({
name: '趋势线',
type: 'line',
data: trendLineData,
symbol: 'none',
lineStyle: {
color: '#FF6B6B',
width: 2,
type: 'dashed',
opacity: 0.8,
},
z: 50,
});
legendData.push('趋势线');
}
// 添加主图指标
if (mainIndicator === 'MA') {
series.push(
@@ -1067,6 +1352,110 @@ export const getKLineDarkGoldOption = (
}
);
legendData.push('RSI6', 'RSI12', 'RSI24');
} else if (subIndicator === 'WR') {
series.push(
{
name: 'WR14',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: wr14,
smooth: true,
symbol: 'none',
lineStyle: { color: gold, width: 1 },
},
{
name: 'WR6',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: wr6,
smooth: true,
symbol: 'none',
lineStyle: { color: cyan, width: 1 },
}
);
legendData.push('WR14', 'WR6');
} else if (subIndicator === 'CCI') {
series.push({
name: 'CCI',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: cci14,
smooth: true,
symbol: 'none',
lineStyle: { color: gold, width: 1.5 },
});
// 添加超买超卖参考线
series.push(
{
name: '+100',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: dates.map(() => 100),
symbol: 'none',
lineStyle: { color: red, width: 1, type: 'dashed', opacity: 0.5 },
silent: true,
},
{
name: '-100',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: dates.map(() => -100),
symbol: 'none',
lineStyle: { color: green, width: 1, type: 'dashed', opacity: 0.5 },
silent: true,
}
);
legendData.push('CCI');
} else if (subIndicator === 'BIAS') {
series.push(
{
name: 'BIAS6',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: bias6,
smooth: true,
symbol: 'none',
lineStyle: { color: gold, width: 1 },
},
{
name: 'BIAS12',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: bias12,
smooth: true,
symbol: 'none',
lineStyle: { color: cyan, width: 1 },
},
{
name: 'BIAS24',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: bias24,
smooth: true,
symbol: 'none',
lineStyle: { color: purple, width: 1 },
}
);
// 添加零轴参考线
series.push({
name: '零轴',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: dates.map(() => 0),
symbol: 'none',
lineStyle: { color: textMuted, width: 1, type: 'dashed', opacity: 0.5 },
silent: true,
});
legendData.push('BIAS6', 'BIAS12', 'BIAS24');
}
return {
@@ -1091,6 +1480,32 @@ export const getKLineDarkGoldOption = (
borderColor: gold,
borderWidth: 1,
textStyle: { color: textColor, fontSize: 11 },
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number | null; value: number | null }[];
if (!paramsArr || paramsArr.length === 0) return '';
const dateStr = paramsArr[0].name;
let result = `<div style="font-weight: bold; color: ${gold}; margin-bottom: 6px;">${dateStr}</div>`;
paramsArr.forEach((param) => {
const { seriesName, marker, data, value } = param;
if (seriesName === 'K线' && Array.isArray(data)) {
const [open, close, low, high] = data;
result += `${marker} 开:${formatPrice(open)} 收:${formatPrice(close)} 低:${formatPrice(low)} 高:${formatPrice(high)}<br/>`;
} else if (seriesName === '成交量' && typeof value === 'number') {
const volStr = value >= 100000000 ? (value / 100000000).toFixed(2) + '亿' :
value >= 10000 ? (value / 10000).toFixed(0) + '万' : value.toString();
result += `${marker} 成交量:${volStr}<br/>`;
} else if (seriesName === '涨幅分析') {
// 跳过涨幅分析标记
} else if (typeof value === 'number' || (value !== null && !isNaN(Number(value)))) {
// MA、BOLL、MACD、KDJ、RSI 等指标
result += `${marker} ${seriesName}${formatPrice(Number(value))}<br/>`;
}
});
return result;
},
},
axisPointer: {
link: [{ xAxisIndex: 'all' }],
@@ -1218,20 +1633,20 @@ export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): ECh
let result = `<div style="font-weight: bold; color: ${gold}; margin-bottom: 6px;">${timeStr}</div>`;
result += `<div style="display: flex; justify-content: space-between; margin-bottom: 3px;">`;
result += `<span>现价:</span><span style="color: ${changeColor}; font-weight: bold;">${price.toFixed(2)}</span></div>`;
result += `<span>现价:</span><span style="color: ${changeColor}; font-weight: bold;">${formatPrice(price)}</span></div>`;
result += `<div style="display: flex; justify-content: space-between; margin-bottom: 3px;">`;
result += `<span>涨跌:</span><span style="color: ${changeColor};">${changeSign}${change.toFixed(2)} (${changeSign}${changePercent.toFixed(2)}%)</span></div>`;
result += `<span>涨跌:</span><span style="color: ${changeColor};">${changeSign}${formatPrice(change)} (${changeSign}${formatPercent(changePercent)}%)</span></div>`;
// 查找均价
const avgPrice = avgPrices[dataIndex];
if (avgPrice !== null) {
result += `<div style="display: flex; justify-content: space-between; margin-bottom: 3px;">`;
result += `<span>均价:</span><span style="color: ${orange};">${avgPrice.toFixed(2)}</span></div>`;
result += `<span>均价:</span><span style="color: ${orange};">${formatPrice(avgPrice)}</span></div>`;
}
// 成交量
const volume = item.volume;
const volumeStr = volume >= 10000 ? (volume / 10000).toFixed(1) + '万' : volume.toString();
const volumeStr = volume >= 10000 ? formatPrice(volume / 10000) + '万' : volume.toString();
result += `<div style="display: flex; justify-content: space-between;">`;
result += `<span>成交:</span><span style="color: ${goldLight};">${volumeStr}手</span></div>`;