refactor: MarketDataView TypeScript 重构 - 2060 行拆分为 12 个模块

- 将原 index.js (2060 行) 重构为 TypeScript 模块化架构
- 新增 types.ts: 383 行类型定义 (Theme, TradeDayData, MinuteData 等)
- 新增 services/marketService.ts: API 服务层封装
- 新增 hooks/useMarketData.ts: 数据获取 Hook
- 新增 utils/formatUtils.ts: 格式化工具函数
- 新增 utils/chartOptions.ts: ECharts 图表配置生成器 (698 行)
- 新增 components/: ThemedCard, MarkdownRenderer, StockSummaryCard, AnalysisModal
- 添加 Company/STRUCTURE.md 目录结构文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-10 15:14:23 +08:00
parent 722d038b56
commit bfb6ef63d0
14 changed files with 3605 additions and 2060 deletions

View File

@@ -0,0 +1,698 @@
// src/views/Company/components/MarketDataView/utils/chartOptions.ts
// MarketDataView ECharts 图表配置生成器
import type { EChartsOption } from 'echarts';
import type {
Theme,
TradeDayData,
MinuteData,
FundingDayData,
PledgeData,
RiseAnalysis,
} from '../types';
import { formatNumber } from './formatUtils';
/**
* 计算移动平均线
* @param data 收盘价数组
* @param period 周期
*/
export const calculateMA = (data: number[], period: number): (number | null)[] => {
const result: (number | null)[] = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
result.push(null);
continue;
}
let sum = 0;
for (let j = 0; j < period; j++) {
sum += data[i - j];
}
result.push(sum / period);
}
return result;
};
/**
* 生成日K线图配置
*/
export const getKLineOption = (
theme: Theme,
tradeData: TradeDayData[],
analysisMap: Record<number, RiseAnalysis>
): EChartsOption => {
if (!tradeData || tradeData.length === 0) return {};
const dates = tradeData.map((item) => item.date.substring(5, 10));
const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = tradeData.map((item) => item.volume);
const closePrices = tradeData.map((item) => item.close);
const ma5 = calculateMA(closePrices, 5);
const ma10 = calculateMA(closePrices, 10);
const ma20 = calculateMA(closePrices, 20);
// 创建涨幅分析标记点
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]);
}
});
return {
backgroundColor: theme.chartBg,
animation: true,
legend: {
data: ['K线', 'MA5', 'MA10', 'MA20'],
top: 10,
textStyle: {
color: theme.textPrimary,
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
lineStyle: {
color: theme.primary,
width: 1,
opacity: 0.8,
},
},
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
},
xAxis: [
{
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
type: 'category',
gridIndex: 1,
data: dates,
boundaryGap: false,
axisLine: { onZero: false, lineStyle: { color: theme.textMuted } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
],
yAxis: [
{
scale: true,
splitLine: {
show: true,
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
],
grid: [
{
left: '10%',
right: '10%',
height: '50%',
},
{
left: '10%',
right: '10%',
top: '65%',
height: '20%',
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: theme.success,
color0: theme.danger,
borderColor: theme.success,
borderColor0: theme.danger,
},
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
lineStyle: {
color: theme.primary,
width: 1,
},
itemStyle: {
color: theme.primary,
},
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
lineStyle: {
color: theme.info,
width: 1,
},
itemStyle: {
color: theme.info,
},
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
lineStyle: {
color: theme.warning,
width: 1,
},
itemStyle: {
color: theme.warning,
},
},
{
name: '涨幅分析',
type: 'scatter',
data: scatterData,
symbolSize: 30,
symbol: 'pin',
itemStyle: {
color: '#FFD700',
shadowBlur: 10,
shadowColor: 'rgba(255, 215, 0, 0.5)',
},
label: {
show: true,
formatter: '★',
fontSize: 20,
position: 'inside',
color: '#FF6B6B',
},
emphasis: {
scale: 1.5,
itemStyle: {
color: '#FFA500',
},
},
z: 100,
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = tradeData[params.dataIndex];
return item.change_percent >= 0
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成分钟K线图配置
*/
export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null): EChartsOption => {
if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {};
const times = minuteData.data.map((item) => item.time);
const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = minuteData.data.map((item) => item.volume);
const closePrices = minuteData.data.map((item) => item.close);
const avgPrice = calculateMA(closePrices, 5);
const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0;
return {
backgroundColor: theme.chartBg,
title: {
text: `${minuteData.name} 分钟K线 (${minuteData.trade_date})`,
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
fontWeight: 'bold',
},
subtextStyle: {
color: theme.textMuted,
},
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
fontSize: 12,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[];
let result = paramsArr[0].name + '<br/>';
paramsArr.forEach((param) => {
if (param.seriesName === '分钟K线') {
const [open, close, , high] = param.data as number[];
const low = (param.data as number[])[2];
const changePercent =
openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00';
result += `${param.marker} ${param.seriesName}<br/>`;
result += `开盘: <span style="font-weight: bold">${open.toFixed(2)}</span><br/>`;
result += `收盘: <span style="font-weight: bold; color: ${close >= open ? theme.success : theme.danger}">${close.toFixed(2)}</span><br/>`;
result += `最高: <span style="font-weight: bold">${high.toFixed(2)}</span><br/>`;
result += `最低: <span style="font-weight: bold">${low.toFixed(2)}</span><br/>`;
result += `涨跌: <span style="font-weight: bold; color: ${close >= openPrice ? theme.success : theme.danger}">${changePercent}%</span><br/>`;
} else if (param.seriesName === '均价线') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${(param.value as number).toFixed(2)}</span><br/>`;
} else if (param.seriesName === '成交量') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${formatNumber(param.value as number, 0)}</span><br/>`;
}
});
return result;
},
},
legend: {
data: ['分钟K线', '均价线', '成交量'],
top: 35,
textStyle: {
color: theme.textPrimary,
fontSize: 12,
},
itemWidth: 25,
itemHeight: 14,
},
grid: [
{
left: '8%',
right: '8%',
top: '20%',
height: '60%',
},
{
left: '8%',
right: '8%',
top: '83%',
height: '12%',
},
],
xAxis: [
{
type: 'category',
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: {
color: theme.textMuted,
fontSize: 10,
interval: 'auto',
},
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: {
color: theme.textMuted,
fontSize: 10,
},
splitLine: { show: false },
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted, fontSize: 10 },
splitLine: {
lineStyle: {
color: theme.border,
type: 'dashed',
},
},
},
{
gridIndex: 1,
scale: true,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted, fontSize: 10 },
splitLine: { show: false },
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 70,
end: 100,
minValueSpan: 20,
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
top: '95%',
start: 70,
end: 100,
height: 20,
handleSize: '100%',
handleStyle: {
color: theme.primary,
},
textStyle: {
color: theme.textMuted,
},
},
],
series: [
{
name: '分钟K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: theme.success,
color0: theme.danger,
borderColor: theme.success,
borderColor0: theme.danger,
borderWidth: 1,
},
barWidth: '60%',
},
{
name: '均价线',
type: 'line',
data: avgPrice,
smooth: true,
symbol: 'none',
lineStyle: {
color: theme.info,
width: 2,
opacity: 0.8,
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
barWidth: '50%',
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = minuteData.data[params.dataIndex];
return item.close >= item.open
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成融资融券图表配置
*/
export const getFundingOption = (theme: Theme, fundingData: FundingDayData[]): EChartsOption => {
if (!fundingData || fundingData.length === 0) return {};
const dates = fundingData.map((item) => item.date.substring(5, 10));
const financing = fundingData.map((item) => item.financing.balance / 100000000);
const securities = fundingData.map((item) => item.securities.balance_amount / 100000000);
return {
backgroundColor: theme.chartBg,
title: {
text: '融资融券余额走势',
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[];
let result = paramsArr[0].name + '<br/>';
paramsArr.forEach((param) => {
result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿<br/>`;
});
return result;
},
},
legend: {
data: ['融资余额', '融券余额'],
bottom: 10,
textStyle: {
color: theme.textPrimary,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
yAxis: {
type: 'value',
name: '金额(亿)',
nameTextStyle: { color: theme.textMuted },
splitLine: {
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
series: [
{
name: '融资余额',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 68, 68, 0.3)' },
{ offset: 1, color: 'rgba(255, 68, 68, 0.05)' },
],
},
},
lineStyle: {
color: theme.success,
width: 2,
},
itemStyle: {
color: theme.success,
borderColor: theme.success,
borderWidth: 2,
},
data: financing,
},
{
name: '融券余额',
type: 'line',
smooth: true,
symbol: 'diamond',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 200, 81, 0.3)' },
{ offset: 1, color: 'rgba(0, 200, 81, 0.05)' },
],
},
},
lineStyle: {
color: theme.danger,
width: 2,
},
itemStyle: {
color: theme.danger,
borderColor: theme.danger,
borderWidth: 2,
},
data: securities,
},
],
};
};
/**
* 生成股权质押图表配置
*/
export const getPledgeOption = (theme: Theme, pledgeData: PledgeData[]): EChartsOption => {
if (!pledgeData || pledgeData.length === 0) return {};
const dates = pledgeData.map((item) => item.end_date.substring(5, 10));
const ratios = pledgeData.map((item) => item.pledge_ratio);
const counts = pledgeData.map((item) => item.pledge_count);
return {
backgroundColor: theme.chartBg,
title: {
text: '股权质押趋势',
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
},
legend: {
data: ['质押比例', '质押笔数'],
bottom: 10,
textStyle: {
color: theme.textPrimary,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
yAxis: [
{
type: 'value',
name: '质押比例(%)',
nameTextStyle: { color: theme.textMuted },
splitLine: {
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
type: 'value',
name: '质押笔数',
nameTextStyle: { color: theme.textMuted },
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
],
series: [
{
name: '质押比例',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: theme.warning,
width: 2,
shadowBlur: 10,
shadowColor: theme.warning,
},
itemStyle: {
color: theme.warning,
borderColor: theme.bgCard,
borderWidth: 2,
},
data: ratios,
},
{
name: '质押笔数',
type: 'bar',
yAxisIndex: 1,
barWidth: '50%',
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: theme.primary },
{ offset: 1, color: theme.primaryDark },
],
},
borderRadius: [5, 5, 0, 0],
},
data: counts,
},
],
};
};
export default {
calculateMA,
getKLineOption,
getMinuteKLineOption,
getFundingOption,
getPledgeOption,
};

View File

@@ -0,0 +1,175 @@
// src/views/Company/components/MarketDataView/utils/formatUtils.ts
// MarketDataView 格式化工具函数
/**
* 格式化数字(自动转换为万/亿)
* @param value 数值
* @param decimals 小数位数,默认 2
* @returns 格式化后的字符串
*/
export const formatNumber = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (Math.abs(num) >= 100000000) {
return (num / 100000000).toFixed(decimals) + '亿';
} else if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(decimals) + '万';
}
return num.toFixed(decimals);
};
/**
* 格式化百分比
* @param value 数值(已经是百分比形式,如 3.5 表示 3.5%
* @param decimals 小数位数,默认 2
* @returns 格式化后的字符串
*/
export const formatPercent = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
return num.toFixed(decimals) + '%';
};
/**
* 格式化日期(取前 10 位)
* @param dateStr 日期字符串
* @returns 格式化后的日期YYYY-MM-DD
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '-';
return dateStr.substring(0, 10);
};
/**
* 格式化价格
* @param value 价格数值
* @param decimals 小数位数,默认 2
* @returns 格式化后的价格字符串
*/
export const formatPrice = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
return num.toFixed(decimals);
};
/**
* 格式化成交量(带单位)
* @param value 成交量数值
* @returns 格式化后的成交量字符串
*/
export const formatVolume = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (num >= 100000000) {
return (num / 100000000).toFixed(2) + '亿股';
} else if (num >= 10000) {
return (num / 10000).toFixed(2) + '万股';
}
return num.toFixed(0) + '股';
};
/**
* 格式化金额(带单位)
* @param value 金额数值
* @returns 格式化后的金额字符串
*/
export const formatAmount = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (Math.abs(num) >= 100000000) {
return (num / 100000000).toFixed(2) + '亿';
} else if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(2) + '万';
}
return num.toFixed(2) + '元';
};
/**
* 格式化涨跌幅(带符号和颜色提示)
* @param value 涨跌幅数值
* @returns 带符号的涨跌幅字符串
*/
export const formatChange = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
const sign = num > 0 ? '+' : '';
return sign + num.toFixed(2) + '%';
};
/**
* 获取涨跌颜色类型
* @param value 涨跌幅数值
* @returns 'up' | 'down' | 'neutral'
*/
export const getChangeType = (value: number | null | undefined): 'up' | 'down' | 'neutral' => {
if (value === null || value === undefined) return 'neutral';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num) || num === 0) return 'neutral';
return num > 0 ? 'up' : 'down';
};
/**
* 格式化短日期MM-DD
* @param dateStr 日期字符串
* @returns 格式化后的短日期
*/
export const formatShortDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '-';
return dateStr.substring(5, 10);
};
/**
* 格式化时间HH:mm
* @param timeStr 时间字符串
* @returns 格式化后的时间
*/
export const formatTime = (timeStr: string | null | undefined): string => {
if (!timeStr) return '-';
// 支持多种格式
if (timeStr.includes(':')) {
return timeStr.substring(0, 5);
}
// 如果是 HHmm 格式
if (timeStr.length >= 4) {
return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4);
}
return timeStr;
};
/**
* 工具函数集合(兼容旧代码)
*/
export const formatUtils = {
formatNumber,
formatPercent,
formatDate,
formatPrice,
formatVolume,
formatAmount,
formatChange,
getChangeType,
formatShortDate,
formatTime,
};
export default formatUtils;