Compare commits
22 Commits
276b280cb9
...
2eb2a22495
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb2a22495 | ||
|
|
6a4c475d3a | ||
|
|
e08b9d2104 | ||
|
|
3f1f438440 | ||
|
|
24720dbba0 | ||
|
|
7877c41e9c | ||
|
|
b25d48e167 | ||
|
|
804de885e1 | ||
|
|
6738a09e3a | ||
|
|
67340e9b82 | ||
|
|
00f2937a34 | ||
|
|
91ed649220 | ||
|
|
391955f88c | ||
|
|
59f4b1cdb9 | ||
|
|
3d6d01964d | ||
|
|
3f3e13bddd | ||
|
|
d27cf5b7d8 | ||
|
|
03bc2d681b | ||
|
|
1022fa4077 | ||
|
|
406b951e53 | ||
|
|
7f392619e7 | ||
|
|
09ca7265d7 |
@@ -10,59 +10,252 @@ export const generateFinancialData = (stockCode) => {
|
||||
|
||||
// 股票基本信息
|
||||
stockInfo: {
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
stock_code: stockCode,
|
||||
stock_name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||
list_date: '1991-04-03',
|
||||
market: 'SZ'
|
||||
market: 'SZ',
|
||||
// 关键指标
|
||||
key_metrics: {
|
||||
eps: 2.72,
|
||||
roe: 16.23,
|
||||
gross_margin: 71.92,
|
||||
net_margin: 32.56,
|
||||
roa: 1.05
|
||||
},
|
||||
// 增长率
|
||||
growth_rates: {
|
||||
revenue_growth: 8.2,
|
||||
profit_growth: 12.5,
|
||||
asset_growth: 5.6,
|
||||
equity_growth: 6.8
|
||||
},
|
||||
// 财务概要
|
||||
financial_summary: {
|
||||
revenue: 162350,
|
||||
net_profit: 52860,
|
||||
total_assets: 5024560,
|
||||
total_liabilities: 4698880
|
||||
},
|
||||
// 最新业绩预告
|
||||
latest_forecast: {
|
||||
forecast_type: '预增',
|
||||
content: '预计全年净利润同比增长10%-17%'
|
||||
}
|
||||
},
|
||||
|
||||
// 资产负债表
|
||||
// 资产负债表 - 嵌套结构
|
||||
balanceSheet: periods.map((period, i) => ({
|
||||
period,
|
||||
total_assets: 5024560 - i * 50000, // 百万元
|
||||
total_liabilities: 4698880 - i * 48000,
|
||||
shareholders_equity: 325680 - i * 2000,
|
||||
current_assets: 2512300 - i * 25000,
|
||||
non_current_assets: 2512260 - i * 25000,
|
||||
current_liabilities: 3456780 - i * 35000,
|
||||
non_current_liabilities: 1242100 - i * 13000
|
||||
assets: {
|
||||
current_assets: {
|
||||
cash: 856780 - i * 10000,
|
||||
trading_financial_assets: 234560 - i * 5000,
|
||||
notes_receivable: 12340 - i * 200,
|
||||
accounts_receivable: 45670 - i * 1000,
|
||||
prepayments: 8900 - i * 100,
|
||||
other_receivables: 23450 - i * 500,
|
||||
inventory: 156780 - i * 3000,
|
||||
contract_assets: 34560 - i * 800,
|
||||
other_current_assets: 67890 - i * 1500,
|
||||
total: 2512300 - i * 25000
|
||||
},
|
||||
non_current_assets: {
|
||||
long_term_equity_investments: 234560 - i * 5000,
|
||||
investment_property: 45670 - i * 1000,
|
||||
fixed_assets: 678900 - i * 15000,
|
||||
construction_in_progress: 123450 - i * 3000,
|
||||
right_of_use_assets: 34560 - i * 800,
|
||||
intangible_assets: 89012 - i * 2000,
|
||||
goodwill: 45670 - i * 1000,
|
||||
deferred_tax_assets: 12340 - i * 300,
|
||||
other_non_current_assets: 67890 - i * 1500,
|
||||
total: 2512260 - i * 25000
|
||||
},
|
||||
total: 5024560 - i * 50000
|
||||
},
|
||||
liabilities: {
|
||||
current_liabilities: {
|
||||
short_term_borrowings: 456780 - i * 10000,
|
||||
notes_payable: 23450 - i * 500,
|
||||
accounts_payable: 234560 - i * 5000,
|
||||
advance_receipts: 12340 - i * 300,
|
||||
contract_liabilities: 34560 - i * 800,
|
||||
employee_compensation_payable: 45670 - i * 1000,
|
||||
taxes_payable: 23450 - i * 500,
|
||||
other_payables: 78900 - i * 1500,
|
||||
non_current_liabilities_due_within_one_year: 89012 - i * 2000,
|
||||
total: 3456780 - i * 35000
|
||||
},
|
||||
non_current_liabilities: {
|
||||
long_term_borrowings: 678900 - i * 15000,
|
||||
bonds_payable: 234560 - i * 5000,
|
||||
lease_liabilities: 45670 - i * 1000,
|
||||
deferred_tax_liabilities: 12340 - i * 300,
|
||||
other_non_current_liabilities: 89012 - i * 2000,
|
||||
total: 1242100 - i * 13000
|
||||
},
|
||||
total: 4698880 - i * 48000
|
||||
},
|
||||
equity: {
|
||||
share_capital: 19405,
|
||||
capital_reserve: 89012 - i * 2000,
|
||||
surplus_reserve: 45670 - i * 1000,
|
||||
undistributed_profit: 156780 - i * 3000,
|
||||
treasury_stock: 0,
|
||||
other_comprehensive_income: 12340 - i * 300,
|
||||
parent_company_equity: 315680 - i * 1800,
|
||||
minority_interests: 10000 - i * 200,
|
||||
total: 325680 - i * 2000
|
||||
}
|
||||
})),
|
||||
|
||||
// 利润表
|
||||
// 利润表 - 嵌套结构
|
||||
incomeStatement: periods.map((period, i) => ({
|
||||
period,
|
||||
revenue: 162350 - i * 4000, // 百万元
|
||||
operating_cost: 45620 - i * 1200,
|
||||
gross_profit: 116730 - i * 2800,
|
||||
operating_profit: 68450 - i * 1500,
|
||||
net_profit: 52860 - i * 1200,
|
||||
eps: 2.72 - i * 0.06
|
||||
revenue: {
|
||||
total_operating_revenue: 162350 - i * 4000,
|
||||
operating_revenue: 158900 - i * 3900,
|
||||
other_income: 3450 - i * 100
|
||||
},
|
||||
costs: {
|
||||
total_operating_cost: 93900 - i * 2500,
|
||||
operating_cost: 45620 - i * 1200,
|
||||
taxes_and_surcharges: 4560 - i * 100,
|
||||
selling_expenses: 12340 - i * 300,
|
||||
admin_expenses: 15670 - i * 400,
|
||||
rd_expenses: 8900 - i * 200,
|
||||
financial_expenses: 6810 - i * 300,
|
||||
interest_expense: 8900 - i * 200,
|
||||
interest_income: 2090 - i * 50,
|
||||
three_expenses_total: 34820 - i * 1000,
|
||||
four_expenses_total: 43720 - i * 1200,
|
||||
asset_impairment_loss: 1200 - i * 50,
|
||||
credit_impairment_loss: 2340 - i * 100
|
||||
},
|
||||
other_gains: {
|
||||
fair_value_change: 1230 - i * 50,
|
||||
investment_income: 3450 - i * 100,
|
||||
investment_income_from_associates: 890 - i * 20,
|
||||
exchange_income: 560 - i * 10,
|
||||
asset_disposal_income: 340 - i * 10
|
||||
},
|
||||
profit: {
|
||||
operating_profit: 68450 - i * 1500,
|
||||
total_profit: 69500 - i * 1500,
|
||||
income_tax_expense: 16640 - i * 300,
|
||||
net_profit: 52860 - i * 1200,
|
||||
parent_net_profit: 51200 - i * 1150,
|
||||
minority_profit: 1660 - i * 50,
|
||||
continuing_operations_net_profit: 52860 - i * 1200,
|
||||
discontinued_operations_net_profit: 0
|
||||
},
|
||||
non_operating: {
|
||||
non_operating_income: 1050 - i * 20,
|
||||
non_operating_expenses: 450 - i * 10
|
||||
},
|
||||
per_share: {
|
||||
basic_eps: 2.72 - i * 0.06,
|
||||
diluted_eps: 2.70 - i * 0.06
|
||||
},
|
||||
comprehensive_income: {
|
||||
other_comprehensive_income: 890 - i * 20,
|
||||
total_comprehensive_income: 53750 - i * 1220,
|
||||
parent_comprehensive_income: 52050 - i * 1170,
|
||||
minority_comprehensive_income: 1700 - i * 50
|
||||
}
|
||||
})),
|
||||
|
||||
// 现金流量表
|
||||
// 现金流量表 - 嵌套结构
|
||||
cashflow: periods.map((period, i) => ({
|
||||
period,
|
||||
operating_cashflow: 125600 - i * 3000, // 百万元
|
||||
investing_cashflow: -45300 - i * 1000,
|
||||
financing_cashflow: -38200 + i * 500,
|
||||
net_cashflow: 42100 - i * 1500,
|
||||
cash_ending: 456780 - i * 10000
|
||||
operating_activities: {
|
||||
inflow: {
|
||||
cash_from_sales: 178500 - i * 4500
|
||||
},
|
||||
outflow: {
|
||||
cash_for_goods: 52900 - i * 1500
|
||||
},
|
||||
net_flow: 125600 - i * 3000
|
||||
},
|
||||
investment_activities: {
|
||||
net_flow: -45300 - i * 1000
|
||||
},
|
||||
financing_activities: {
|
||||
net_flow: -38200 + i * 500
|
||||
},
|
||||
cash_changes: {
|
||||
net_increase: 42100 - i * 1500,
|
||||
ending_balance: 456780 - i * 10000
|
||||
},
|
||||
key_metrics: {
|
||||
free_cash_flow: 80300 - i * 2000
|
||||
}
|
||||
})),
|
||||
|
||||
// 财务指标
|
||||
// 财务指标 - 嵌套结构
|
||||
financialMetrics: periods.map((period, i) => ({
|
||||
period,
|
||||
roe: 16.23 - i * 0.3, // %
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_margin: 32.56 - i * 0.3,
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
debt_ratio: 93.52 + i * 0.05,
|
||||
asset_turnover: 0.41 - i * 0.01,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
receivable_turnover: 0 // 银行特殊
|
||||
profitability: {
|
||||
roe: 16.23 - i * 0.3,
|
||||
roe_deducted: 15.89 - i * 0.3,
|
||||
roe_weighted: 16.45 - i * 0.3,
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_profit_margin: 32.56 - i * 0.3,
|
||||
operating_profit_margin: 42.16 - i * 0.4,
|
||||
cost_profit_ratio: 115.8 - i * 1.2,
|
||||
ebit: 86140 - i * 1800
|
||||
},
|
||||
per_share_metrics: {
|
||||
eps: 2.72 - i * 0.06,
|
||||
basic_eps: 2.72 - i * 0.06,
|
||||
diluted_eps: 2.70 - i * 0.06,
|
||||
deducted_eps: 2.65 - i * 0.06,
|
||||
bvps: 16.78 - i * 0.1,
|
||||
operating_cash_flow_ps: 6.47 - i * 0.15,
|
||||
capital_reserve_ps: 4.59 - i * 0.1,
|
||||
undistributed_profit_ps: 8.08 - i * 0.15
|
||||
},
|
||||
growth: {
|
||||
revenue_growth: 8.2 - i * 0.5,
|
||||
net_profit_growth: 12.5 - i * 0.8,
|
||||
deducted_profit_growth: 11.8 - i * 0.7,
|
||||
parent_profit_growth: 12.3 - i * 0.75,
|
||||
operating_cash_flow_growth: 15.6 - i * 1.0,
|
||||
total_asset_growth: 5.6 - i * 0.3,
|
||||
equity_growth: 6.8 - i * 0.4,
|
||||
fixed_asset_growth: 4.2 - i * 0.2
|
||||
},
|
||||
operational_efficiency: {
|
||||
total_asset_turnover: 0.41 - i * 0.01,
|
||||
fixed_asset_turnover: 2.35 - i * 0.05,
|
||||
current_asset_turnover: 0.82 - i * 0.02,
|
||||
receivable_turnover: 12.5 - i * 0.3,
|
||||
receivable_days: 29.2 + i * 0.7,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
inventory_days: 0,
|
||||
working_capital_turnover: 1.68 - i * 0.04
|
||||
},
|
||||
solvency: {
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
cash_ratio: 0.25 + i * 0.005,
|
||||
conservative_quick_ratio: 0.68 + i * 0.01,
|
||||
asset_liability_ratio: 93.52 + i * 0.05,
|
||||
interest_coverage: 8.56 - i * 0.2,
|
||||
cash_to_maturity_debt_ratio: 0.45 - i * 0.01,
|
||||
tangible_asset_debt_ratio: 94.12 + i * 0.05
|
||||
},
|
||||
expense_ratios: {
|
||||
selling_expense_ratio: 7.60 + i * 0.1,
|
||||
admin_expense_ratio: 9.65 + i * 0.1,
|
||||
financial_expense_ratio: 4.19 + i * 0.1,
|
||||
rd_expense_ratio: 5.48 + i * 0.1,
|
||||
three_expense_ratio: 21.44 + i * 0.3,
|
||||
four_expense_ratio: 26.92 + i * 0.4,
|
||||
cost_ratio: 28.10 + i * 0.2
|
||||
}
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
@@ -92,18 +285,29 @@ export const generateFinancialData = (stockCode) => {
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
|
||||
// 行业排名
|
||||
industryRank: {
|
||||
industry: '银行',
|
||||
total_companies: 42,
|
||||
rankings: [
|
||||
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
|
||||
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
|
||||
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
|
||||
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
|
||||
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
|
||||
]
|
||||
},
|
||||
// 行业排名(数组格式,符合 IndustryRankingView 组件要求)
|
||||
industryRank: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '三季报',
|
||||
rankings: [
|
||||
{
|
||||
industry_name: stockCode === '000001' ? '银行' : '制造业',
|
||||
level_description: '一级行业',
|
||||
metrics: {
|
||||
eps: { value: 2.72, rank: 8, industry_avg: 1.85 },
|
||||
bvps: { value: 15.23, rank: 12, industry_avg: 12.50 },
|
||||
roe: { value: 16.23, rank: 10, industry_avg: 12.00 },
|
||||
revenue_growth: { value: 8.2, rank: 15, industry_avg: 5.50 },
|
||||
profit_growth: { value: 12.5, rank: 9, industry_avg: 8.00 },
|
||||
operating_margin: { value: 32.56, rank: 6, industry_avg: 25.00 },
|
||||
debt_ratio: { value: 92.5, rank: 35, industry_avg: 88.00 },
|
||||
receivable_turnover: { value: 5.2, rank: 18, industry_avg: 4.80 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// 期间对比
|
||||
periodComparison: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Company 目录结构说明
|
||||
|
||||
> 最后更新:2025-12-12
|
||||
> 最后更新:2025-12-16
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -69,7 +69,7 @@ src/views/Company/
|
||||
│ ├── MarketDataView/ # Tab: 股票行情(TypeScript)
|
||||
│ │ ├── index.tsx # 主组件入口(~285 行,Tab 容器)
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── constants.ts # 主题配置、常量
|
||||
│ │ ├── constants.ts # 主题配置、常量(含黑金主题 darkGoldTheme)
|
||||
│ │ ├── services/
|
||||
│ │ │ └── marketService.ts # API 服务层
|
||||
│ │ ├── hooks/
|
||||
@@ -81,11 +81,31 @@ src/views/Company/
|
||||
│ │ ├── index.ts # 组件导出
|
||||
│ │ ├── ThemedCard.tsx # 主题化卡片
|
||||
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
|
||||
│ │ ├── StockSummaryCard.tsx # 股票概览卡片
|
||||
│ │ ├── AnalysisModal.tsx # 涨幅分析模态框
|
||||
│ │ ├── StockSummaryCard/ # 股票概览卡片(黑金主题 4 列布局)
|
||||
│ │ │ ├── index.tsx # 主组件(4 列 SimpleGrid 布局)
|
||||
│ │ │ ├── StockHeaderCard.tsx # 股票信息卡片(名称、价格、涨跌幅)
|
||||
│ │ │ ├── MetricCard.tsx # 指标卡片模板
|
||||
│ │ │ ├── utils.ts # 状态计算工具函数
|
||||
│ │ │ └── atoms/ # 原子组件
|
||||
│ │ │ ├── index.ts # 原子组件导出
|
||||
│ │ │ ├── DarkGoldCard.tsx # 黑金主题卡片容器
|
||||
│ │ │ ├── CardTitle.tsx # 卡片标题(图标+标题+副标题)
|
||||
│ │ │ ├── MetricValue.tsx # 核心数值展示
|
||||
│ │ │ ├── PriceDisplay.tsx # 价格显示(价格+涨跌箭头)
|
||||
│ │ │ └── StatusTag.tsx # 状态标签(活跃/健康等)
|
||||
│ │ └── panels/ # Tab 面板组件
|
||||
│ │ ├── index.ts # 面板组件统一导出
|
||||
│ │ ├── TradeDataPanel.tsx # 交易数据(K线图、分钟图、表格)
|
||||
│ │ ├── TradeDataPanel/ # 交易数据面板(原子设计模式)
|
||||
│ │ │ ├── index.tsx # 主入口组件(~50 行)
|
||||
│ │ │ ├── KLineChart.tsx # 日K线图组件(~40 行)
|
||||
│ │ │ ├── MinuteKLineSection.tsx # 分钟K线区域(~95 行)
|
||||
│ │ │ ├── TradeTable.tsx # 交易明细表格(~75 行)
|
||||
│ │ │ └── atoms/ # 原子组件
|
||||
│ │ │ ├── index.ts # 统一导出
|
||||
│ │ │ ├── MinuteStats.tsx # 分钟数据统计(~80 行)
|
||||
│ │ │ ├── TradeAnalysis.tsx # 成交分析(~65 行)
|
||||
│ │ │ └── EmptyState.tsx # 空状态组件(~35 行)
|
||||
│ │ ├── FundingPanel.tsx # 融资融券面板
|
||||
│ │ ├── BigDealPanel.tsx # 大宗交易面板
|
||||
│ │ ├── UnusualPanel.tsx # 龙虎榜面板
|
||||
@@ -834,4 +854,149 @@ MarketDataView/components/panels/
|
||||
**设计原则**:
|
||||
- **职责分离**:主组件只负责 Tab 容器和状态管理
|
||||
- **组件复用**:面板组件可独立测试和维护
|
||||
- **类型安全**:每个面板组件有独立的 Props 类型定义
|
||||
- **类型安全**:每个面板组件有独立的 Props 类型定义
|
||||
|
||||
### 2025-12-16 StockSummaryCard 黑金主题重构
|
||||
|
||||
**改动概述**:
|
||||
- `StockSummaryCard.tsx` 从单文件重构为**原子设计模式**的目录结构
|
||||
- 布局从 **1+3**(头部+三卡片)改为 **4 列横向排列**
|
||||
- 新增**黑金主题**(`darkGoldTheme`)
|
||||
- 提取 **5 个原子组件** + **2 个业务组件**
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
StockSummaryCard/
|
||||
├── index.tsx # 主组件(4 列 SimpleGrid 布局)
|
||||
├── StockHeaderCard.tsx # 股票信息卡片(名称、价格、涨跌幅、走势)
|
||||
├── MetricCard.tsx # 指标卡片模板组件
|
||||
├── utils.ts # 状态计算工具函数
|
||||
└── atoms/ # 原子组件
|
||||
├── index.ts # 统一导出
|
||||
├── DarkGoldCard.tsx # 黑金主题卡片容器(渐变背景、金色边框)
|
||||
├── CardTitle.tsx # 卡片标题(图标+标题+副标题)
|
||||
├── MetricValue.tsx # 核心数值展示(标签+数值+后缀)
|
||||
├── PriceDisplay.tsx # 价格显示(价格+涨跌箭头+百分比)
|
||||
└── StatusTag.tsx # 状态标签(活跃/健康/警惕等)
|
||||
```
|
||||
|
||||
**4 列布局设计**:
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 股票信息 │ │ 交易热度 │ │ 估值VS安全 │ │ 情绪与风险 │
|
||||
│ 平安银行 │ │ (流动性) │ │ (便宜否) │ │ (资金面) │
|
||||
│ (000001) │ │ │ │ │ │ │
|
||||
│ 13.50 ↗+1.89%│ │ 成交额 46.79亿│ │ PE 4.96 │ │ 融资 58.23亿 │
|
||||
│ 走势:小幅上涨 │ │ 成交量|换手率 │ │ 质押率(健康) │ │ 融券 1.26亿 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**黑金主题配置**(`constants.ts`):
|
||||
```typescript
|
||||
export const darkGoldTheme = {
|
||||
bgCard: 'linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
gold: '#D4AF37',
|
||||
orange: '#FF9500',
|
||||
green: '#00C851',
|
||||
red: '#FF4444',
|
||||
textPrimary: '#FFFFFF',
|
||||
textMuted: 'rgba(255, 255, 255, 0.6)',
|
||||
};
|
||||
```
|
||||
|
||||
**状态计算工具**(`utils.ts`):
|
||||
| 函数 | 功能 |
|
||||
|------|------|
|
||||
| `getTrendDescription` | 根据涨跌幅返回走势描述(强势上涨/小幅下跌等) |
|
||||
| `getTurnoverStatus` | 换手率状态(≥3% 活跃, ≥1% 正常, <1% 冷清) |
|
||||
| `getPEStatus` | 市盈率估值评级(极低估值/合理/偏高/泡沫风险) |
|
||||
| `getPledgeStatus` | 质押率健康状态(<10% 健康, <30% 正常, <50% 偏高, ≥50% 警惕) |
|
||||
| `getPriceColor` | 根据涨跌返回颜色(红涨绿跌) |
|
||||
|
||||
**原子组件说明**:
|
||||
| 组件 | 行数 | 用途 | 可复用场景 |
|
||||
|------|------|------|-----------|
|
||||
| `DarkGoldCard` | ~40 | 黑金主题卡片容器 | 任何需要黑金风格的卡片 |
|
||||
| `CardTitle` | ~30 | 卡片标题行 | 带图标的标题展示 |
|
||||
| `MetricValue` | ~45 | 核心数值展示 | 各种指标数值展示 |
|
||||
| `PriceDisplay` | ~55 | 价格+涨跌幅 | 股票价格展示 |
|
||||
| `StatusTag` | ~20 | 状态标签 | 各种状态文字标签 |
|
||||
|
||||
**响应式断点**:
|
||||
- `lg` (≥992px): 4 列
|
||||
- `md` (≥768px): 2 列
|
||||
- `base` (<768px): 1 列
|
||||
|
||||
**类型定义更新**(`types.ts`):
|
||||
- `StockSummaryCardProps.theme` 改为可选参数,组件内置使用 `darkGoldTheme`
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | ~350 | ~115 | -67% |
|
||||
| 文件数量 | 1 | 8 | 原子设计模式 |
|
||||
| 可复用组件 | 0 | 5 原子 + 2 业务 | 提升 |
|
||||
| 主题支持 | 依赖传入 | 内置黑金主题 | 独立 |
|
||||
|
||||
**设计原则**:
|
||||
- **原子设计模式**:atoms(基础元素)→ 业务组件(MetricCard、StockHeaderCard)→ 页面组件(index.tsx)
|
||||
- **主题独立**:StockSummaryCard 使用内置黑金主题,不依赖外部传入
|
||||
- **职责分离**:状态计算逻辑提取到 `utils.ts`,UI 与逻辑解耦
|
||||
- **组件复用**:原子组件可在其他黑金主题场景复用
|
||||
|
||||
### 2025-12-16 TradeDataPanel 原子设计模式拆分
|
||||
|
||||
**改动概述**:
|
||||
- `TradeDataPanel.tsx` 从 **382 行** 拆分为 **8 个 TypeScript 文件**
|
||||
- 采用**原子设计模式**组织代码
|
||||
- 提取 **3 个原子组件** + **3 个业务组件**
|
||||
|
||||
**拆分后文件结构**:
|
||||
```
|
||||
TradeDataPanel/
|
||||
├── index.tsx # 主入口组件(~50 行,组合 3 个子组件)
|
||||
├── KLineChart.tsx # 日K线图组件(~40 行)
|
||||
├── MinuteKLineSection.tsx # 分钟K线区域(~95 行,含加载/空状态处理)
|
||||
├── TradeTable.tsx # 交易明细表格(~75 行)
|
||||
└── atoms/ # 原子组件
|
||||
├── index.ts # 统一导出
|
||||
├── MinuteStats.tsx # 分钟数据统计(~80 行,4 个 Stat 卡片)
|
||||
├── TradeAnalysis.tsx # 成交分析(~65 行,活跃时段/平均价格等)
|
||||
└── EmptyState.tsx # 空状态组件(~35 行,可复用)
|
||||
```
|
||||
|
||||
**组件依赖关系**:
|
||||
```
|
||||
index.tsx
|
||||
├── KLineChart # 日K线图(ECharts)
|
||||
├── MinuteKLineSection # 分钟K线区域
|
||||
│ ├── MinuteStats (atom) # 开盘/当前/最高/最低价统计
|
||||
│ ├── TradeAnalysis (atom) # 成交数据分析
|
||||
│ └── EmptyState (atom) # 空状态提示
|
||||
└── TradeTable # 交易明细表格(最近 10 天)
|
||||
```
|
||||
|
||||
**组件职责**:
|
||||
| 组件 | 行数 | 功能 |
|
||||
|------|------|------|
|
||||
| `index.tsx` | ~50 | 主入口,组合 3 个子组件 |
|
||||
| `KLineChart` | ~40 | 日K线图渲染,支持图表点击事件 |
|
||||
| `MinuteKLineSection` | ~95 | 分钟K线区域,含加载状态、空状态、统计数据 |
|
||||
| `TradeTable` | ~75 | 最近 10 天交易明细表格 |
|
||||
| `MinuteStats` | ~80 | 分钟数据四宫格统计(开盘/当前/最高/最低价) |
|
||||
| `TradeAnalysis` | ~65 | 成交数据分析(活跃时段、平均价格、数据点数) |
|
||||
| `EmptyState` | ~35 | 通用空状态组件(可配置标题和描述) |
|
||||
|
||||
**优化效果**:
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| 主文件行数 | 382 | ~50 | -87% |
|
||||
| 文件数量 | 1 | 8 | 原子设计模式 |
|
||||
| 可复用组件 | 0 | 3 原子 + 3 业务 | 提升 |
|
||||
|
||||
**设计原则**:
|
||||
- **原子设计模式**:atoms(MinuteStats、TradeAnalysis、EmptyState)→ 业务组件(KLineChart、MinuteKLineSection、TradeTable)→ 主组件
|
||||
- **职责分离**:图表、统计、表格各自独立
|
||||
- **组件复用**:EmptyState 可在其他场景复用
|
||||
- **类型安全**:完整的 Props 类型定义和导出
|
||||
@@ -2,6 +2,7 @@
|
||||
* 竞争地位分析卡片
|
||||
*
|
||||
* 显示竞争力评分、雷达图和竞争分析
|
||||
* 包含行业排名弹窗功能
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
@@ -22,6 +23,14 @@ import {
|
||||
Icon,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaTrophy,
|
||||
@@ -33,11 +42,32 @@ import {
|
||||
FaShieldAlt,
|
||||
FaRocket,
|
||||
FaUsers,
|
||||
FaExternalLinkAlt,
|
||||
} from 'react-icons/fa';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { ScoreBar } from '../atoms';
|
||||
import { getRadarChartOption } from '../utils/chartOptions';
|
||||
import type { ComprehensiveData, CompetitivePosition } from '../types';
|
||||
import { IndustryRankingView } from '../../../FinancialPanorama/components';
|
||||
import type { ComprehensiveData, CompetitivePosition, IndustryRankData } from '../types';
|
||||
|
||||
// 黑金主题弹窗样式
|
||||
const MODAL_STYLES = {
|
||||
content: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: '1px',
|
||||
maxW: '900px',
|
||||
},
|
||||
header: {
|
||||
color: 'yellow.500',
|
||||
borderBottomColor: 'rgba(212, 175, 55, 0.2)',
|
||||
borderBottomWidth: '1px',
|
||||
},
|
||||
closeButton: {
|
||||
color: 'yellow.500',
|
||||
_hover: { bg: 'rgba(212, 175, 55, 0.1)' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// 样式常量 - 避免每次渲染创建新对象
|
||||
const CARD_STYLES = {
|
||||
@@ -57,6 +87,7 @@ const CHART_STYLE = { height: '320px' } as const;
|
||||
|
||||
interface CompetitiveAnalysisCardProps {
|
||||
comprehensiveData: ComprehensiveData;
|
||||
industryRankData?: IndustryRankData[];
|
||||
}
|
||||
|
||||
// 竞争对手标签组件
|
||||
@@ -141,8 +172,10 @@ const AdvantagesSection = memo<AdvantagesSectionProps>(
|
||||
AdvantagesSection.displayName = 'AdvantagesSection';
|
||||
|
||||
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
({ comprehensiveData }) => {
|
||||
({ comprehensiveData, industryRankData }) => {
|
||||
const competitivePosition = comprehensiveData.competitive_position;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
if (!competitivePosition) return null;
|
||||
|
||||
// 缓存雷达图配置
|
||||
@@ -160,56 +193,99 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
[competitivePosition.analysis?.main_competitors]
|
||||
);
|
||||
|
||||
// 判断是否有行业排名数据可展示
|
||||
const hasIndustryRankData = industryRankData && industryRankData.length > 0;
|
||||
|
||||
return (
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaTrophy} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">竞争地位分析</Heading>
|
||||
{competitivePosition.ranking && (
|
||||
<Badge
|
||||
ml={2}
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
>
|
||||
行业排名 {competitivePosition.ranking.industry_rank}/
|
||||
{competitivePosition.ranking.total_companies}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 主要竞争对手 */}
|
||||
{/* {competitors.length > 0 && <CompetitorTags competitors={competitors} />} */}
|
||||
<>
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaTrophy} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">竞争地位分析</Heading>
|
||||
{competitivePosition.ranking && (
|
||||
<Badge
|
||||
ml={2}
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
cursor={hasIndustryRankData ? 'pointer' : 'default'}
|
||||
onClick={hasIndustryRankData ? onOpen : undefined}
|
||||
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
|
||||
>
|
||||
行业排名 {competitivePosition.ranking.industry_rank}/
|
||||
{competitivePosition.ranking.total_companies}
|
||||
</Badge>
|
||||
)}
|
||||
{hasIndustryRankData && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="yellow.500"
|
||||
rightIcon={<Icon as={FaExternalLinkAlt} boxSize={3} />}
|
||||
onClick={onOpen}
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 主要竞争对手 */}
|
||||
{/* {competitors.length > 0 && <CompetitorTags competitors={competitors} />} */}
|
||||
|
||||
{/* 评分和雷达图 */}
|
||||
{/* <Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
<ScoreSection scores={competitivePosition.scores} />
|
||||
</GridItem>
|
||||
{/* 评分和雷达图 */}
|
||||
{/* <Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
<ScoreSection scores={competitivePosition.scores} />
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
{radarOption && (
|
||||
<ReactECharts
|
||||
option={radarOption}
|
||||
style={CHART_STYLE}
|
||||
theme="dark"
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
{radarOption && (
|
||||
<ReactECharts
|
||||
option={radarOption}
|
||||
style={CHART_STYLE}
|
||||
theme="dark"
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid> */}
|
||||
|
||||
{/* <Divider my={4} borderColor="yellow.600" /> */}
|
||||
|
||||
{/* 竞争优势和劣势 */}
|
||||
<AdvantagesSection
|
||||
advantages={competitivePosition.analysis?.competitive_advantages}
|
||||
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 行业排名弹窗 - 黑金主题 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
|
||||
<ModalOverlay bg="blackAlpha.700" />
|
||||
<ModalContent {...MODAL_STYLES.content}>
|
||||
<ModalHeader {...MODAL_STYLES.header}>
|
||||
<HStack>
|
||||
<Icon as={FaTrophy} color="yellow.500" />
|
||||
<Text>行业排名详情</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton {...MODAL_STYLES.closeButton} />
|
||||
<ModalBody py={4}>
|
||||
{hasIndustryRankData && (
|
||||
<IndustryRankingView
|
||||
industryRank={industryRankData}
|
||||
bgColor="gray.800"
|
||||
borderColor="rgba(212, 175, 55, 0.3)"
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid> */}
|
||||
|
||||
{/* <Divider my={4} borderColor="yellow.600" /> */}
|
||||
|
||||
{/* 竞争优势和劣势 */}
|
||||
<AdvantagesSection
|
||||
advantages={competitivePosition.analysis?.competitive_advantages}
|
||||
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -47,6 +47,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
comprehensiveData,
|
||||
valueChainData,
|
||||
keyFactorsData,
|
||||
industryRankData,
|
||||
loading,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
@@ -96,6 +97,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
comprehensiveData,
|
||||
valueChainData,
|
||||
keyFactorsData,
|
||||
industryRankData,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 战略分析 Tab
|
||||
*
|
||||
* 包含:核心定位 + 战略分析 + 竞争地位分析
|
||||
* 包含:核心定位 + 战略分析 + 竞争地位分析(含行业排名弹窗)
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
@@ -11,15 +11,17 @@ import {
|
||||
StrategyAnalysisCard,
|
||||
CompetitiveAnalysisCard,
|
||||
} from '../components';
|
||||
import type { ComprehensiveData } from '../types';
|
||||
import type { ComprehensiveData, IndustryRankData } from '../types';
|
||||
|
||||
export interface StrategyTabProps {
|
||||
comprehensiveData?: ComprehensiveData;
|
||||
industryRankData?: IndustryRankData[];
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const StrategyTab: React.FC<StrategyTabProps> = memo(({
|
||||
comprehensiveData,
|
||||
industryRankData,
|
||||
cardBg,
|
||||
}) => {
|
||||
return (
|
||||
@@ -40,9 +42,12 @@ const StrategyTab: React.FC<StrategyTabProps> = memo(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 竞争地位分析 */}
|
||||
{/* 竞争地位分析(包含行业排名弹窗) */}
|
||||
{comprehensiveData?.competitive_position && (
|
||||
<CompetitiveAnalysisCard comprehensiveData={comprehensiveData} />
|
||||
<CompetitiveAnalysisCard
|
||||
comprehensiveData={comprehensiveData}
|
||||
industryRankData={industryRankData}
|
||||
/>
|
||||
)}
|
||||
</TabPanelContainer>
|
||||
);
|
||||
|
||||
@@ -265,6 +265,35 @@ export interface KeyFactorsData {
|
||||
development_timeline?: DevelopmentTimeline;
|
||||
}
|
||||
|
||||
// ==================== 行业排名类型 ====================
|
||||
|
||||
/** 行业排名指标 */
|
||||
export interface RankingMetric {
|
||||
value?: number;
|
||||
rank?: number;
|
||||
industry_avg?: number;
|
||||
}
|
||||
|
||||
/** 行业排名数据 */
|
||||
export interface IndustryRankData {
|
||||
period: string;
|
||||
report_type: string;
|
||||
rankings?: {
|
||||
industry_name: string;
|
||||
level_description: string;
|
||||
metrics?: {
|
||||
eps?: RankingMetric;
|
||||
bvps?: RankingMetric;
|
||||
roe?: RankingMetric;
|
||||
revenue_growth?: RankingMetric;
|
||||
profit_growth?: RankingMetric;
|
||||
operating_margin?: RankingMetric;
|
||||
debt_ratio?: RankingMetric;
|
||||
receivable_turnover?: RankingMetric;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
// ==================== 主组件 Props 类型 ====================
|
||||
|
||||
/** Tab 类型 */
|
||||
@@ -274,6 +303,7 @@ export interface DeepAnalysisTabProps {
|
||||
comprehensiveData?: ComprehensiveData;
|
||||
valueChainData?: ValueChainData;
|
||||
keyFactorsData?: KeyFactorsData;
|
||||
industryRankData?: IndustryRankData[];
|
||||
loading?: boolean;
|
||||
cardBg?: string;
|
||||
expandedSegments: Record<number, boolean>;
|
||||
|
||||
@@ -40,17 +40,20 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
const [comprehensiveData, setComprehensiveData] = useState(null);
|
||||
const [valueChainData, setValueChainData] = useState(null);
|
||||
const [keyFactorsData, setKeyFactorsData] = useState(null);
|
||||
const [industryRankData, setIndustryRankData] = useState(null);
|
||||
|
||||
// 各接口独立的 loading 状态
|
||||
const [comprehensiveLoading, setComprehensiveLoading] = useState(false);
|
||||
const [valueChainLoading, setValueChainLoading] = useState(false);
|
||||
const [keyFactorsLoading, setKeyFactorsLoading] = useState(false);
|
||||
const [industryRankLoading, setIndustryRankLoading] = useState(false);
|
||||
|
||||
// 已加载的接口记录(用于缓存判断)
|
||||
const loadedApisRef = useRef({
|
||||
comprehensive: false,
|
||||
valueChain: false,
|
||||
keyFactors: false,
|
||||
industryRank: false,
|
||||
});
|
||||
|
||||
// 业务板块展开状态
|
||||
@@ -114,6 +117,17 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
}
|
||||
break;
|
||||
|
||||
case "industryRank":
|
||||
setIndustryRankLoading(true);
|
||||
const industryRankRes = await fetch(
|
||||
`${API_BASE_URL}/api/financial/industry-rank/${stockCode}`
|
||||
).then((r) => r.json());
|
||||
if (currentStockCodeRef.current === stockCode) {
|
||||
if (industryRankRes.success) setIndustryRankData(industryRankRes.data);
|
||||
loadedApisRef.current.industryRank = true;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -126,6 +140,7 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
if (apiKey === "comprehensive") setComprehensiveLoading(false);
|
||||
if (apiKey === "valueChain") setValueChainLoading(false);
|
||||
if (apiKey === "keyFactors") setKeyFactorsLoading(false);
|
||||
if (apiKey === "industryRank") setIndustryRankLoading(false);
|
||||
}
|
||||
},
|
||||
[stockCode]
|
||||
@@ -165,17 +180,20 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
setComprehensiveData(null);
|
||||
setValueChainData(null);
|
||||
setKeyFactorsData(null);
|
||||
setIndustryRankData(null);
|
||||
setExpandedSegments({});
|
||||
loadedApisRef.current = {
|
||||
comprehensive: false,
|
||||
valueChain: false,
|
||||
keyFactors: false,
|
||||
industryRank: false,
|
||||
};
|
||||
|
||||
// 重置为默认 Tab 并加载数据
|
||||
setActiveTab("strategy");
|
||||
// 加载默认 Tab 的数据
|
||||
// 加载默认 Tab 的数据(战略分析需要 comprehensive 和 industryRank)
|
||||
loadApiData("comprehensive");
|
||||
loadApiData("industryRank");
|
||||
}
|
||||
}, [stockCode, loadApiData]);
|
||||
|
||||
@@ -199,6 +217,7 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
comprehensiveData={comprehensiveData}
|
||||
valueChainData={valueChainData}
|
||||
keyFactorsData={keyFactorsData}
|
||||
industryRankData={industryRankData}
|
||||
loading={getCurrentLoading()}
|
||||
cardBg="white"
|
||||
expandedSegments={expandedSegments}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// src/views/Company/components/DynamicTracking/components/ForecastPanel.js
|
||||
// 业绩预告面板
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Card,
|
||||
CardBody,
|
||||
HStack,
|
||||
Badge,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import { THEME } from '../../CompanyOverview/BasicInfoTab/config';
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
const ForecastPanel = ({ stockCode }) => {
|
||||
const [forecast, setForecast] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadForecast = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/forecast`
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setForecast(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('ForecastPanel', 'loadForecast', err, { stockCode });
|
||||
setForecast(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadForecast();
|
||||
}, [loadForecast]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={10}>
|
||||
<Spinner size="lg" color={THEME.gold} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!forecast?.forecasts?.length) {
|
||||
return (
|
||||
<Center py={10}>
|
||||
<Text color={THEME.textSecondary}>暂无业绩预告数据</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{forecast.forecasts.map((item, idx) => (
|
||||
<Card key={idx} bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
|
||||
<CardBody>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Badge colorScheme="blue">{item.forecast_type}</Badge>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
报告期: {item.report_date}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text mb={2} color={THEME.text}>{item.content}</Text>
|
||||
{item.reason && (
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
{item.reason}
|
||||
</Text>
|
||||
)}
|
||||
{item.change_range?.lower && (
|
||||
<HStack mt={2}>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>预计变动范围:</Text>
|
||||
<Badge colorScheme="green">
|
||||
{item.change_range.lower}% ~ {item.change_range.upper}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastPanel;
|
||||
@@ -0,0 +1,115 @@
|
||||
// src/views/Company/components/DynamicTracking/components/NewsPanel.js
|
||||
// 新闻动态面板(包装 NewsEventsTab)
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
const NewsPanel = ({ stockCode }) => {
|
||||
const [newsEvents, setNewsEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stockName, setStockName] = useState('');
|
||||
|
||||
// 获取股票名称
|
||||
const fetchStockName = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
||||
setStockName(name);
|
||||
return name;
|
||||
}
|
||||
return stockCode;
|
||||
} catch (err) {
|
||||
logger.error('NewsPanel', 'fetchStockName', err, { stockCode });
|
||||
return stockCode;
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
// 加载新闻事件
|
||||
const loadNewsEvents = useCallback(
|
||||
async (query, page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const searchTerm = query || stockName || stockCode;
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setNewsEvents(result.data || []);
|
||||
setPagination({
|
||||
page: result.pagination?.page || page,
|
||||
per_page: result.pagination?.per_page || 10,
|
||||
total: result.pagination?.total || 0,
|
||||
pages: result.pagination?.pages || 0,
|
||||
has_next: result.pagination?.has_next || false,
|
||||
has_prev: result.pagination?.has_prev || false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('NewsPanel', 'loadNewsEvents', err, { stockCode });
|
||||
setNewsEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[stockCode, stockName]
|
||||
);
|
||||
|
||||
// 首次加载
|
||||
useEffect(() => {
|
||||
const initLoad = async () => {
|
||||
if (stockCode) {
|
||||
const name = await fetchStockName();
|
||||
await loadNewsEvents(name, 1);
|
||||
}
|
||||
};
|
||||
initLoad();
|
||||
}, [stockCode, fetchStockName, loadNewsEvents]);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearchChange = (value) => {
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
loadNewsEvents(searchQuery || stockName, 1);
|
||||
};
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page) => {
|
||||
loadNewsEvents(searchQuery || stockName, page);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewsEventsTab
|
||||
newsEvents={newsEvents}
|
||||
newsLoading={loading}
|
||||
newsPagination={pagination}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearch={handleSearch}
|
||||
onPageChange={handlePageChange}
|
||||
cardBg="white"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsPanel;
|
||||
@@ -0,0 +1,4 @@
|
||||
// src/views/Company/components/DynamicTracking/components/index.js
|
||||
|
||||
export { default as NewsPanel } from './NewsPanel';
|
||||
export { default as ForecastPanel } from './ForecastPanel';
|
||||
@@ -1,204 +1,65 @@
|
||||
// src/views/Company/components/DynamicTracking/index.js
|
||||
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab)
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaNewspaper, FaBullhorn, FaCalendarAlt } from "react-icons/fa";
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
|
||||
|
||||
import { logger } from "@utils/logger";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
import NewsEventsTab from "../CompanyOverview/NewsEventsTab";
|
||||
import AnnouncementsPanel from "../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel";
|
||||
import DisclosureSchedulePanel from "../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel";
|
||||
import { THEME } from "../CompanyOverview/BasicInfoTab/config";
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = getApiBase();
|
||||
import SubTabContainer from '@components/SubTabContainer';
|
||||
import AnnouncementsPanel from '../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel';
|
||||
import DisclosureSchedulePanel from '../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel';
|
||||
import { NewsPanel, ForecastPanel } from './components';
|
||||
|
||||
// 二级 Tab 配置
|
||||
const TRACKING_TABS = [
|
||||
{ key: "news", name: "新闻动态", icon: FaNewspaper },
|
||||
{ key: "announcements", name: "公司公告", icon: FaBullhorn },
|
||||
{ key: "disclosure", name: "财报披露日程", icon: FaCalendarAlt },
|
||||
{ key: 'news', name: '新闻动态', icon: FaNewspaper, component: NewsPanel },
|
||||
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel },
|
||||
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel },
|
||||
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel },
|
||||
];
|
||||
|
||||
/**
|
||||
* 动态跟踪组件
|
||||
*
|
||||
* 功能:
|
||||
* - 二级 Tab 结构
|
||||
* - Tab1: 新闻动态(复用 NewsEventsTab)
|
||||
* - 预留后续扩展
|
||||
* - 使用 SubTabContainer 实现二级导航
|
||||
* - Tab1: 新闻动态
|
||||
* - Tab2: 公司公告
|
||||
* - Tab3: 财报披露日程
|
||||
* - Tab4: 业绩预告
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.stockCode - 股票代码
|
||||
*/
|
||||
const DynamicTracking = ({ stockCode: propStockCode }) => {
|
||||
const [stockCode, setStockCode] = useState(propStockCode || "000001");
|
||||
const [stockCode, setStockCode] = useState(propStockCode || '000001');
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// 新闻动态状态
|
||||
const [newsEvents, setNewsEvents] = useState([]);
|
||||
const [newsLoading, setNewsLoading] = useState(false);
|
||||
const [newsPagination, setNewsPagination] = useState({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [stockName, setStockName] = useState("");
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
// 监听 props 中的 stockCode 变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== stockCode) {
|
||||
setStockCode(propStockCode);
|
||||
setDataLoaded(false);
|
||||
setNewsEvents([]);
|
||||
setStockName("");
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
// 获取股票名称(用于搜索)
|
||||
const fetchStockName = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
|
||||
setStockName(name);
|
||||
return name;
|
||||
}
|
||||
return stockCode;
|
||||
} catch (err) {
|
||||
logger.error("DynamicTracking", "fetchStockName", err, { stockCode });
|
||||
return stockCode;
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
// 加载新闻事件数据
|
||||
const loadNewsEvents = useCallback(
|
||||
async (query, page = 1) => {
|
||||
setNewsLoading(true);
|
||||
try {
|
||||
const searchTerm = query || stockName || stockCode;
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setNewsEvents(result.data || []);
|
||||
setNewsPagination({
|
||||
page: result.pagination?.page || page,
|
||||
per_page: result.pagination?.per_page || 10,
|
||||
total: result.pagination?.total || 0,
|
||||
pages: result.pagination?.pages || 0,
|
||||
has_next: result.pagination?.has_next || false,
|
||||
has_prev: result.pagination?.has_prev || false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("DynamicTracking", "loadNewsEvents", err, { stockCode });
|
||||
setNewsEvents([]);
|
||||
} finally {
|
||||
setNewsLoading(false);
|
||||
}
|
||||
},
|
||||
[stockCode, stockName]
|
||||
// 传递给子组件的 props
|
||||
const componentProps = useMemo(
|
||||
() => ({
|
||||
stockCode,
|
||||
}),
|
||||
[stockCode]
|
||||
);
|
||||
|
||||
// 首次加载
|
||||
useEffect(() => {
|
||||
const initLoad = async () => {
|
||||
if (stockCode && !dataLoaded) {
|
||||
const name = await fetchStockName();
|
||||
await loadNewsEvents(name, 1);
|
||||
setDataLoaded(true);
|
||||
}
|
||||
};
|
||||
initLoad();
|
||||
}, [stockCode, dataLoaded, fetchStockName, loadNewsEvents]);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearchChange = (value) => {
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
loadNewsEvents(searchQuery || stockName, 1);
|
||||
};
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page) => {
|
||||
loadNewsEvents(searchQuery || stockName, page);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={THEME.bg} p={4} borderRadius="md">
|
||||
<Tabs
|
||||
variant="soft-rounded"
|
||||
<Box>
|
||||
<SubTabContainer
|
||||
tabs={TRACKING_TABS}
|
||||
componentProps={componentProps}
|
||||
themePreset="blackGold"
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
onTabChange={(index) => setActiveTab(index)}
|
||||
isLazy
|
||||
>
|
||||
<TabList bg={THEME.cardBg} borderBottom="1px solid" borderColor={THEME.border}>
|
||||
{TRACKING_TABS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
fontWeight="medium"
|
||||
color={THEME.textSecondary}
|
||||
_selected={{
|
||||
color: THEME.tabSelected.color,
|
||||
bg: THEME.tabSelected.bg,
|
||||
borderRadius: "md",
|
||||
}}
|
||||
_hover={{ color: THEME.gold }}
|
||||
>
|
||||
{tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 新闻动态 Tab */}
|
||||
<TabPanel p={4}>
|
||||
<NewsEventsTab
|
||||
newsEvents={newsEvents}
|
||||
newsLoading={newsLoading}
|
||||
newsPagination={newsPagination}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearch={handleSearch}
|
||||
onPageChange={handlePageChange}
|
||||
cardBg="white"
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* 公司公告 Tab */}
|
||||
<TabPanel p={4}>
|
||||
<AnnouncementsPanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 财报披露日程 Tab */}
|
||||
<TabPanel p={4}>
|
||||
<DisclosureSchedulePanel stockCode={stockCode} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,14 +21,23 @@ import type { IndustryRankingViewProps } from '../types';
|
||||
|
||||
export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
|
||||
industryRank,
|
||||
bgColor,
|
||||
borderColor,
|
||||
bgColor = 'white',
|
||||
borderColor = 'gray.200',
|
||||
textColor,
|
||||
labelColor,
|
||||
}) => {
|
||||
if (!industryRank || industryRank.length === 0) {
|
||||
// 判断是否为深色主题
|
||||
const isDarkTheme = bgColor === 'gray.800' || bgColor === 'gray.900';
|
||||
const resolvedTextColor = textColor || (isDarkTheme ? 'white' : 'gray.800');
|
||||
const resolvedLabelColor = labelColor || (isDarkTheme ? 'gray.400' : 'gray.500');
|
||||
const cardBg = isDarkTheme ? 'transparent' : 'white';
|
||||
const headingColor = isDarkTheme ? 'yellow.500' : 'gray.800';
|
||||
|
||||
if (!industryRank || !Array.isArray(industryRank) || industryRank.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<Text textAlign="center" color="gray.500" py={8}>
|
||||
<Text textAlign="center" color={resolvedLabelColor} py={8}>
|
||||
暂无行业排名数据
|
||||
</Text>
|
||||
</CardBody>
|
||||
@@ -39,17 +48,32 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{industryRank.map((periodData, periodIdx) => (
|
||||
<Card key={periodIdx}>
|
||||
<CardHeader>
|
||||
<Card
|
||||
key={periodIdx}
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderWidth="1px"
|
||||
>
|
||||
<CardHeader pb={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">{periodData.report_type} 行业排名</Heading>
|
||||
<Badge colorScheme="purple">{periodData.period}</Badge>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
{periodData.report_type} 行业排名
|
||||
</Heading>
|
||||
<Badge
|
||||
bg={isDarkTheme ? 'transparent' : undefined}
|
||||
borderWidth={isDarkTheme ? '1px' : 0}
|
||||
borderColor={isDarkTheme ? 'yellow.600' : undefined}
|
||||
color={isDarkTheme ? 'yellow.500' : undefined}
|
||||
colorScheme={isDarkTheme ? undefined : 'purple'}
|
||||
>
|
||||
{periodData.period}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CardBody pt={2}>
|
||||
{periodData.rankings?.map((ranking, idx) => (
|
||||
<Box key={idx} mb={6}>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
<Text fontWeight="bold" mb={3} color={resolvedTextColor}>
|
||||
{ranking.industry_name} ({ranking.level_description})
|
||||
</Text>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
|
||||
@@ -65,6 +89,15 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
|
||||
metric.key.includes('margin') ||
|
||||
metric.key === 'roe';
|
||||
|
||||
// 格式化数值
|
||||
const formattedValue = isPercentMetric
|
||||
? formatUtils.formatPercent(metricData.value)
|
||||
: metricData.value?.toFixed(2) ?? '-';
|
||||
|
||||
const formattedAvg = isPercentMetric
|
||||
? formatUtils.formatPercent(metricData.industry_avg)
|
||||
: metricData.industry_avg?.toFixed(2) ?? '-';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={metric.key}
|
||||
@@ -74,14 +107,12 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={resolvedLabelColor}>
|
||||
{metric.name}
|
||||
</Text>
|
||||
<HStack mt={1}>
|
||||
<Text fontWeight="bold">
|
||||
{isPercentMetric
|
||||
? formatUtils.formatPercent(metricData.value)
|
||||
: metricData.value?.toFixed(2) || '-'}
|
||||
<HStack mt={1} spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="lg" color={resolvedTextColor}>
|
||||
{formattedValue}
|
||||
</Text>
|
||||
{metricData.rank && (
|
||||
<Badge
|
||||
@@ -92,11 +123,8 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
行业均值:{' '}
|
||||
{isPercentMetric
|
||||
? formatUtils.formatPercent(metricData.industry_avg)
|
||||
: metricData.industry_avg?.toFixed(2) || '-'}
|
||||
<Text fontSize="xs" color={resolvedLabelColor} mt={1}>
|
||||
行业均值: {formattedAvg}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 期数选择器组件
|
||||
* 用于选择显示的财务报表期数,并提供刷新功能
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
IconButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
|
||||
export interface PeriodSelectorProps {
|
||||
/** 当前选中的期数 */
|
||||
selectedPeriods: number;
|
||||
/** 期数变更回调 */
|
||||
onPeriodsChange: (periods: number) => void;
|
||||
/** 刷新回调 */
|
||||
onRefresh: () => void;
|
||||
/** 是否加载中 */
|
||||
isLoading?: boolean;
|
||||
/** 可选期数列表,默认 [4, 8, 12, 16] */
|
||||
periodOptions?: number[];
|
||||
/** 标签文本 */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const PeriodSelector: React.FC<PeriodSelectorProps> = memo(({
|
||||
selectedPeriods,
|
||||
onPeriodsChange,
|
||||
onRefresh,
|
||||
isLoading = false,
|
||||
periodOptions = [4, 8, 12, 16],
|
||||
label = '显示期数:',
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{label}
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedPeriods}
|
||||
onChange={(e) => onPeriodsChange(Number(e.target.value))}
|
||||
w="150px"
|
||||
size="sm"
|
||||
>
|
||||
{periodOptions.map((period) => (
|
||||
<option key={period} value={period}>
|
||||
最近{period}期
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
onClick={onRefresh}
|
||||
isLoading={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="刷新数据"
|
||||
/>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
PeriodSelector.displayName = 'PeriodSelector';
|
||||
|
||||
export { PeriodSelector };
|
||||
export default PeriodSelector;
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* 股票信息头部组件
|
||||
* 股票信息头部组件 - 黑金主题
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Box,
|
||||
Grid,
|
||||
GridItem,
|
||||
VStack,
|
||||
@@ -18,93 +17,153 @@ import {
|
||||
StatNumber,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import type { StockInfoHeaderProps } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const darkGoldTheme = {
|
||||
bgCard: 'rgba(26, 32, 44, 0.95)',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
borderHover: 'rgba(212, 175, 55, 0.5)',
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F4D03F',
|
||||
orange: '#FF9500',
|
||||
red: '#FF4444',
|
||||
green: '#00C851',
|
||||
textPrimary: 'rgba(255, 255, 255, 0.92)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
||||
textMuted: 'rgba(255, 255, 255, 0.5)',
|
||||
tagBg: 'rgba(212, 175, 55, 0.15)',
|
||||
};
|
||||
|
||||
export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
|
||||
stockInfo,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
}) => {
|
||||
if (!stockInfo) return null;
|
||||
|
||||
return (
|
||||
<Card mb={4}>
|
||||
<CardBody>
|
||||
<Grid templateColumns="repeat(6, 1fr)" gap={4}>
|
||||
<GridItem colSpan={{ base: 6, md: 2 }}>
|
||||
<VStack align="start">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
股票名称
|
||||
</Text>
|
||||
<HStack>
|
||||
<Heading size="md">{stockInfo.stock_name}</Heading>
|
||||
<Badge>{stockInfo.stock_code}</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel>最新EPS</StatLabel>
|
||||
<StatNumber>
|
||||
{stockInfo.key_metrics?.eps?.toFixed(3) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel>ROE</StatLabel>
|
||||
<StatNumber>
|
||||
{formatUtils.formatPercent(stockInfo.key_metrics?.roe)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel>营收增长</StatLabel>
|
||||
<StatNumber
|
||||
color={
|
||||
stockInfo.growth_rates?.revenue_growth
|
||||
? stockInfo.growth_rates.revenue_growth > 0
|
||||
? positiveColor
|
||||
: negativeColor
|
||||
: 'gray.500'
|
||||
}
|
||||
<Box
|
||||
mb={4}
|
||||
bg={darkGoldTheme.bgCard}
|
||||
border="1px solid"
|
||||
borderColor={darkGoldTheme.border}
|
||||
borderRadius="xl"
|
||||
p={5}
|
||||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
borderColor: darkGoldTheme.borderHover,
|
||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||
}}
|
||||
>
|
||||
<Grid templateColumns="repeat(6, 1fr)" gap={4} alignItems="center">
|
||||
<GridItem colSpan={{ base: 6, md: 2 }}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
股票名称
|
||||
</Text>
|
||||
<HStack>
|
||||
<Heading
|
||||
size="md"
|
||||
bgGradient={`linear(to-r, ${darkGoldTheme.gold}, ${darkGoldTheme.orange})`}
|
||||
bgClip="text"
|
||||
>
|
||||
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel>利润增长</StatLabel>
|
||||
<StatNumber
|
||||
color={
|
||||
stockInfo.growth_rates?.profit_growth
|
||||
? stockInfo.growth_rates.profit_growth > 0
|
||||
? positiveColor
|
||||
: negativeColor
|
||||
: 'gray.500'
|
||||
}
|
||||
{stockInfo.stock_name}
|
||||
</Heading>
|
||||
<Badge
|
||||
bg={darkGoldTheme.tagBg}
|
||||
color={darkGoldTheme.gold}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
{stockInfo.latest_forecast && (
|
||||
<Alert status="info" mt={4}>
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text fontWeight="bold">{stockInfo.latest_forecast.forecast_type}</Text>
|
||||
<Text fontSize="sm">{stockInfo.latest_forecast.content}</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{stockInfo.stock_code}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
最新EPS
|
||||
</StatLabel>
|
||||
<StatNumber color={darkGoldTheme.goldLight} fontSize="lg">
|
||||
{stockInfo.key_metrics?.eps?.toFixed(3) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
ROE
|
||||
</StatLabel>
|
||||
<StatNumber color={darkGoldTheme.goldLight} fontSize="lg">
|
||||
{formatUtils.formatPercent(stockInfo.key_metrics?.roe)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
营收增长
|
||||
</StatLabel>
|
||||
<StatNumber
|
||||
fontSize="lg"
|
||||
color={
|
||||
stockInfo.growth_rates?.revenue_growth
|
||||
? stockInfo.growth_rates.revenue_growth > 0
|
||||
? darkGoldTheme.red
|
||||
: darkGoldTheme.green
|
||||
: darkGoldTheme.textMuted
|
||||
}
|
||||
>
|
||||
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Stat>
|
||||
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
利润增长
|
||||
</StatLabel>
|
||||
<StatNumber
|
||||
fontSize="lg"
|
||||
color={
|
||||
stockInfo.growth_rates?.profit_growth
|
||||
? stockInfo.growth_rates.profit_growth > 0
|
||||
? darkGoldTheme.red
|
||||
: darkGoldTheme.green
|
||||
: darkGoldTheme.textMuted
|
||||
}
|
||||
>
|
||||
{formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
{stockInfo.latest_forecast && (
|
||||
<Alert
|
||||
status="info"
|
||||
mt={4}
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={darkGoldTheme.border}
|
||||
>
|
||||
<AlertIcon color={darkGoldTheme.gold} />
|
||||
<Box>
|
||||
<Text fontWeight="bold" color={darkGoldTheme.gold}>
|
||||
{stockInfo.latest_forecast.forecast_type}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={darkGoldTheme.textSecondary}>
|
||||
{stockInfo.latest_forecast.content}
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 组件统一导出
|
||||
*/
|
||||
|
||||
export { PeriodSelector } from './PeriodSelector';
|
||||
export { StockInfoHeader } from './StockInfoHeader';
|
||||
export { BalanceSheetTable } from './BalanceSheetTable';
|
||||
export { IncomeStatementTable } from './IncomeStatementTable';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 财务全景组件
|
||||
* 重构后的主组件,使用模块化结构
|
||||
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
|
||||
*/
|
||||
|
||||
import React, { useState, ReactNode } from 'react';
|
||||
import React, { useState, useMemo, ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -11,20 +11,12 @@ import {
|
||||
HStack,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Select,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Skeleton,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
@@ -40,27 +32,25 @@ import {
|
||||
Td,
|
||||
TableContainer,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
import { BarChart3, DollarSign, TrendingUp } from 'lucide-react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
|
||||
// 通用组件
|
||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||
|
||||
// 内部模块导入
|
||||
import { useFinancialData } from './hooks';
|
||||
import { COLORS } from './constants';
|
||||
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
|
||||
import {
|
||||
StockInfoHeader,
|
||||
BalanceSheetTable,
|
||||
IncomeStatementTable,
|
||||
CashflowTable,
|
||||
FinancialMetricsTable,
|
||||
MainBusinessAnalysis,
|
||||
IndustryRankingView,
|
||||
StockComparison,
|
||||
ComparisonAnalysis,
|
||||
} from './components';
|
||||
import { BalanceSheetTab, IncomeStatementTab, CashflowTab } from './tabs';
|
||||
import type { FinancialPanoramaProps } from './types';
|
||||
|
||||
/**
|
||||
@@ -75,29 +65,24 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
cashflow,
|
||||
financialMetrics,
|
||||
mainBusiness,
|
||||
forecast,
|
||||
industryRank,
|
||||
comparison,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
currentStockCode,
|
||||
selectedPeriods,
|
||||
setSelectedPeriods,
|
||||
} = useFinancialData({ stockCode: propStockCode });
|
||||
|
||||
// UI 状态
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [modalContent, setModalContent] = useState<ReactNode>(null);
|
||||
|
||||
// 颜色配置
|
||||
const { bgColor, hoverBg, positiveColor, negativeColor, borderColor } = COLORS;
|
||||
const { bgColor, hoverBg, positiveColor, negativeColor } = COLORS;
|
||||
|
||||
// 点击指标行显示图表
|
||||
const showMetricChart = (
|
||||
metricName: string,
|
||||
metricKey: string,
|
||||
_metricKey: string,
|
||||
data: Array<{ period: string; [key: string]: unknown }>,
|
||||
dataPath: string
|
||||
) => {
|
||||
@@ -206,6 +191,45 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
// Tab 配置 - 只保留三大财务报表
|
||||
const tabConfigs: SubTabConfig[] = useMemo(
|
||||
() => [
|
||||
{ key: 'balance', name: '资产负债表', icon: BarChart3, component: BalanceSheetTab },
|
||||
{ key: 'income', name: '利润表', icon: DollarSign, component: IncomeStatementTab },
|
||||
{ key: 'cashflow', name: '现金流量表', icon: TrendingUp, component: CashflowTab },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// 传递给 Tab 组件的 props
|
||||
const componentProps = useMemo(
|
||||
() => ({
|
||||
// 数据
|
||||
balanceSheet,
|
||||
incomeStatement,
|
||||
cashflow,
|
||||
// 工具函数
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
// 颜色配置
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}),
|
||||
[
|
||||
balanceSheet,
|
||||
incomeStatement,
|
||||
cashflow,
|
||||
showMetricChart,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxW="container.xl" py={5}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
@@ -252,180 +276,35 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
{/* 财务指标速览 */}
|
||||
{!loading && stockInfo && (
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="enclosed"
|
||||
colorScheme="blue"
|
||||
>
|
||||
<TabList>
|
||||
<Tab>财务概览</Tab>
|
||||
<Tab>资产负债表</Tab>
|
||||
<Tab>利润表</Tab>
|
||||
<Tab>现金流量表</Tab>
|
||||
<Tab>财务指标</Tab>
|
||||
<Tab>主营业务</Tab>
|
||||
<Tab>行业排名</Tab>
|
||||
<Tab>业绩预告</Tab>
|
||||
<Tab>股票对比</Tab>
|
||||
</TabList>
|
||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||
)}
|
||||
|
||||
<TabPanels>
|
||||
{/* 财务概览 */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<ComparisonAnalysis comparison={comparison} />
|
||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
{/* 主营业务 */}
|
||||
{!loading && stockInfo && (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text fontSize="lg" fontWeight="bold" mb={4}>
|
||||
主营业务
|
||||
</Text>
|
||||
<MainBusinessAnalysis mainBusiness={mainBusiness} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 资产负债表 */}
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">资产负债表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(balanceSheet.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:表格可横向滚动查看更多数据,点击行查看历史趋势
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<BalanceSheetTable data={balanceSheet} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* 利润表 */}
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">利润表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(incomeStatement.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* 现金流量表 */}
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">现金流量表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(cashflow.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CashflowTable data={cashflow} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* 财务指标 */}
|
||||
<TabPanel>
|
||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 主营业务 */}
|
||||
<TabPanel>
|
||||
<MainBusinessAnalysis mainBusiness={mainBusiness} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 行业排名 */}
|
||||
<TabPanel>
|
||||
<IndustryRankingView
|
||||
industryRank={industryRank}
|
||||
bgColor={bgColor}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* 业绩预告 */}
|
||||
<TabPanel>
|
||||
{forecast && (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{forecast.forecasts?.map((item, idx) => (
|
||||
<Card key={idx}>
|
||||
<CardBody>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Badge colorScheme="blue">{item.forecast_type}</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
报告期: {item.report_date}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text mb={2}>{item.content}</Text>
|
||||
{item.reason && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{item.reason}
|
||||
</Text>
|
||||
)}
|
||||
{item.change_range?.lower && (
|
||||
<HStack mt={2}>
|
||||
<Text fontSize="sm">预计变动范围:</Text>
|
||||
<Badge colorScheme="green">
|
||||
{item.change_range.lower}% ~ {item.change_range.upper}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* 股票对比 */}
|
||||
<TabPanel>
|
||||
<StockComparison
|
||||
currentStock={currentStockCode}
|
||||
stockInfo={stockInfo}
|
||||
positiveColor={positiveColor}
|
||||
negativeColor={negativeColor}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
|
||||
{!loading && stockInfo && (
|
||||
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={tabConfigs}
|
||||
componentProps={componentProps}
|
||||
themePreset="blackGold"
|
||||
isLazy
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 资产负债表 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { BalanceSheetTable } from '../components';
|
||||
import type { BalanceSheetData } from '../types';
|
||||
|
||||
export interface BalanceSheetTabProps {
|
||||
balanceSheet: BalanceSheetData[];
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
positiveColor: string;
|
||||
negativeColor: string;
|
||||
bgColor: string;
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
|
||||
balanceSheet,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">资产负债表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(balanceSheet.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:表格可横向滚动查看更多数据,点击行查看历史趋势
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<BalanceSheetTable data={balanceSheet} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalanceSheetTab;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 现金流量表 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { CashflowTable } from '../components';
|
||||
import type { CashflowData } from '../types';
|
||||
|
||||
export interface CashflowTabProps {
|
||||
cashflow: CashflowData[];
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
positiveColor: string;
|
||||
negativeColor: string;
|
||||
bgColor: string;
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const CashflowTab: React.FC<CashflowTabProps> = ({
|
||||
cashflow,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">现金流量表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(cashflow.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:现金流数据为累计值,正值红色表示现金流入,负值绿色表示现金流出
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CashflowTable data={cashflow} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashflowTab;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 利润表 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { IncomeStatementTable } from '../components';
|
||||
import type { IncomeStatementData } from '../types';
|
||||
|
||||
export interface IncomeStatementTabProps {
|
||||
incomeStatement: IncomeStatementData[];
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
positiveColor: string;
|
||||
negativeColor: string;
|
||||
bgColor: string;
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
|
||||
incomeStatement,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">利润表</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="blue">
|
||||
显示最近{Math.min(incomeStatement.length, 8)}期
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
红涨绿跌 | 同比变化
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncomeStatementTab;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 主营业务 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MainBusinessAnalysis } from '../components';
|
||||
import type { MainBusinessData } from '../types';
|
||||
|
||||
export interface MainBusinessTabProps {
|
||||
mainBusiness: MainBusinessData | null;
|
||||
}
|
||||
|
||||
const MainBusinessTab: React.FC<MainBusinessTabProps> = ({ mainBusiness }) => {
|
||||
return <MainBusinessAnalysis mainBusiness={mainBusiness} />;
|
||||
};
|
||||
|
||||
export default MainBusinessTab;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 财务指标 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FinancialMetricsTable } from '../components';
|
||||
import type { FinancialMetricsData } from '../types';
|
||||
|
||||
export interface MetricsTabProps {
|
||||
financialMetrics: FinancialMetricsData[];
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
positiveColor: string;
|
||||
negativeColor: string;
|
||||
bgColor: string;
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const MetricsTab: React.FC<MetricsTabProps> = ({
|
||||
financialMetrics,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
return <FinancialMetricsTable data={financialMetrics} {...tableProps} />;
|
||||
};
|
||||
|
||||
export default MetricsTab;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 财务概览 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import { ComparisonAnalysis, FinancialMetricsTable } from '../components';
|
||||
import type { FinancialMetricsData, ComparisonData } from '../types';
|
||||
|
||||
export interface OverviewTabProps {
|
||||
comparison: ComparisonData[];
|
||||
financialMetrics: FinancialMetricsData[];
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
positiveColor: string;
|
||||
negativeColor: string;
|
||||
bgColor: string;
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
comparison,
|
||||
financialMetrics,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<ComparisonAnalysis comparison={comparison} />
|
||||
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewTab;
|
||||
12
src/views/Company/components/FinancialPanorama/tabs/index.ts
Normal file
12
src/views/Company/components/FinancialPanorama/tabs/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Tab 组件统一导出
|
||||
* 仅保留三大财务报表 Tab
|
||||
*/
|
||||
|
||||
export { default as BalanceSheetTab } from './BalanceSheetTab';
|
||||
export { default as IncomeStatementTab } from './IncomeStatementTab';
|
||||
export { default as CashflowTab } from './CashflowTab';
|
||||
|
||||
export type { BalanceSheetTabProps } from './BalanceSheetTab';
|
||||
export type { IncomeStatementTabProps } from './IncomeStatementTab';
|
||||
export type { CashflowTabProps } from './CashflowTab';
|
||||
@@ -392,8 +392,10 @@ export interface MainBusinessAnalysisProps {
|
||||
/** 行业排名 Props */
|
||||
export interface IndustryRankingViewProps {
|
||||
industryRank: IndustryRankData[];
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
bgColor?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
/** 股票对比 Props */
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx
|
||||
// 股票概览卡片组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CardBody,
|
||||
Grid,
|
||||
GridItem,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
} from '@chakra-ui/react';
|
||||
import ThemedCard from './ThemedCard';
|
||||
import { formatNumber, formatPercent } from '../utils/formatUtils';
|
||||
import type { StockSummaryCardProps } from '../types';
|
||||
|
||||
/**
|
||||
* 股票概览卡片组件
|
||||
* 显示股票基本信息、最新交易数据和融资融券数据
|
||||
*/
|
||||
const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary, theme }) => {
|
||||
if (!summary) return null;
|
||||
|
||||
const { latest_trade, latest_funding, latest_pledge } = summary;
|
||||
|
||||
return (
|
||||
<ThemedCard theme={theme}>
|
||||
<CardBody>
|
||||
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
|
||||
{/* 左侧:股票名称和涨跌 */}
|
||||
<GridItem colSpan={{ base: 12, md: 4 }}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack>
|
||||
<Heading size="xl" color={theme.textSecondary}>
|
||||
{summary.stock_name}
|
||||
</Heading>
|
||||
<Badge colorScheme="blue" fontSize="lg">
|
||||
{summary.stock_code}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{latest_trade && (
|
||||
<HStack spacing={4}>
|
||||
<Stat>
|
||||
<StatNumber fontSize="4xl" color={theme.textPrimary}>
|
||||
{latest_trade.close}
|
||||
</StatNumber>
|
||||
<StatHelpText fontSize="lg">
|
||||
<StatArrow
|
||||
type={latest_trade.change_percent >= 0 ? 'increase' : 'decrease'}
|
||||
color={latest_trade.change_percent >= 0 ? theme.success : theme.danger}
|
||||
/>
|
||||
{Math.abs(latest_trade.change_percent).toFixed(2)}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧:详细指标 */}
|
||||
<GridItem colSpan={{ base: 12, md: 8 }}>
|
||||
{/* 交易指标 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
{latest_trade && (
|
||||
<>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>成交量</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{formatNumber(latest_trade.volume, 0)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>成交额</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{formatNumber(latest_trade.amount)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>换手率</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{formatPercent(latest_trade.turnover_rate)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>市盈率</StatLabel>
|
||||
<StatNumber color={theme.textSecondary}>
|
||||
{latest_trade.pe_ratio || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 融资融券和质押指标 */}
|
||||
{latest_funding && (
|
||||
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4} mt={4}>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>融资余额</StatLabel>
|
||||
<StatNumber color={theme.success} fontSize="lg">
|
||||
{formatNumber(latest_funding.financing_balance)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>融券余额</StatLabel>
|
||||
<StatNumber color={theme.danger} fontSize="lg">
|
||||
{formatNumber(latest_funding.securities_balance)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
{latest_pledge && (
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted}>质押比例</StatLabel>
|
||||
<StatNumber color={theme.warning} fontSize="lg">
|
||||
{formatPercent(latest_pledge.pledge_ratio)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockSummaryCard;
|
||||
@@ -0,0 +1,56 @@
|
||||
// 指标卡片组件
|
||||
import React from 'react';
|
||||
import { Box, VStack } from '@chakra-ui/react';
|
||||
import { DarkGoldCard, CardTitle, MetricValue } from './atoms';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
|
||||
export interface MetricCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
leftIcon: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
mainLabel: string;
|
||||
mainValue: string;
|
||||
mainColor: string;
|
||||
mainSuffix?: string;
|
||||
subText: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标卡片组件 - 用于展示单个指标数据
|
||||
*/
|
||||
const MetricCard: React.FC<MetricCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
mainLabel,
|
||||
mainValue,
|
||||
mainColor,
|
||||
mainSuffix,
|
||||
subText,
|
||||
}) => (
|
||||
<DarkGoldCard>
|
||||
<CardTitle
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
leftIcon={leftIcon}
|
||||
rightIcon={rightIcon}
|
||||
/>
|
||||
|
||||
<VStack align="start" spacing={0.5} mb={2}>
|
||||
<MetricValue
|
||||
label={mainLabel}
|
||||
value={mainValue}
|
||||
color={mainColor}
|
||||
suffix={mainSuffix}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Box color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
{subText}
|
||||
</Box>
|
||||
</DarkGoldCard>
|
||||
);
|
||||
|
||||
export default MetricCard;
|
||||
@@ -0,0 +1,90 @@
|
||||
// 股票信息卡片组件(4列布局版本)
|
||||
import React from 'react';
|
||||
import { Box, HStack, Text, Icon } from '@chakra-ui/react';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { DarkGoldCard } from './atoms';
|
||||
import { getTrendDescription, getPriceColor } from './utils';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
|
||||
export interface StockHeaderCardProps {
|
||||
stockName: string;
|
||||
stockCode: string;
|
||||
price: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票信息卡片 - 4 列布局中的第一个卡片
|
||||
*/
|
||||
const StockHeaderCard: React.FC<StockHeaderCardProps> = ({
|
||||
stockName,
|
||||
stockCode,
|
||||
price,
|
||||
changePercent,
|
||||
}) => {
|
||||
const isUp = changePercent >= 0;
|
||||
const priceColor = getPriceColor(changePercent);
|
||||
const trendDesc = getTrendDescription(changePercent);
|
||||
|
||||
return (
|
||||
<DarkGoldCard position="relative" overflow="hidden">
|
||||
{/* 背景装饰线 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
right={0}
|
||||
top={0}
|
||||
width="60%"
|
||||
height="100%"
|
||||
opacity={0.12}
|
||||
background={`linear-gradient(135deg, transparent 30%, ${priceColor})`}
|
||||
clipPath="polygon(40% 0, 100% 0, 100% 100%, 20% 100%)"
|
||||
/>
|
||||
|
||||
{/* 股票名称和代码 */}
|
||||
<HStack spacing={1.5} mb={2}>
|
||||
<Text
|
||||
color={darkGoldTheme.textPrimary}
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{stockName}
|
||||
</Text>
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
({stockCode})
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 价格和涨跌幅 */}
|
||||
<HStack spacing={2} align="baseline" mb={1.5}>
|
||||
<Text
|
||||
color={priceColor}
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
lineHeight="1"
|
||||
>
|
||||
{price.toFixed(2)}
|
||||
</Text>
|
||||
<HStack spacing={0.5} align="center">
|
||||
<Icon
|
||||
as={isUp ? TrendingUp : TrendingDown}
|
||||
color={priceColor}
|
||||
boxSize={3}
|
||||
/>
|
||||
<Text color={priceColor} fontSize="sm" fontWeight="bold">
|
||||
{isUp ? '+' : ''}{changePercent.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 走势简述 */}
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
走势简述:
|
||||
<Text as="span" color={priceColor} fontWeight="medium">
|
||||
{trendDesc}
|
||||
</Text>
|
||||
</Text>
|
||||
</DarkGoldCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockHeaderCard;
|
||||
@@ -0,0 +1,36 @@
|
||||
// 卡片标题原子组件
|
||||
import React from 'react';
|
||||
import { Flex, HStack, Box, Text } from '@chakra-ui/react';
|
||||
import { darkGoldTheme } from '../../../constants';
|
||||
|
||||
interface CardTitleProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
leftIcon: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片标题组件 - 显示图标+标题+副标题
|
||||
*/
|
||||
const CardTitle: React.FC<CardTitleProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
}) => (
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
<HStack spacing={1.5}>
|
||||
<Box color={darkGoldTheme.gold}>{leftIcon}</Box>
|
||||
<Text color={darkGoldTheme.gold} fontSize="sm" fontWeight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="xs">
|
||||
({subtitle})
|
||||
</Text>
|
||||
</HStack>
|
||||
{rightIcon && <Box color={darkGoldTheme.gold}>{rightIcon}</Box>}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export default CardTitle;
|
||||
@@ -0,0 +1,42 @@
|
||||
// 黑金主题卡片容器原子组件
|
||||
import React from 'react';
|
||||
import { Box, BoxProps } from '@chakra-ui/react';
|
||||
import { darkGoldTheme } from '../../../constants';
|
||||
|
||||
interface DarkGoldCardProps extends BoxProps {
|
||||
children: React.ReactNode;
|
||||
hoverable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑金主题卡片容器
|
||||
*/
|
||||
const DarkGoldCard: React.FC<DarkGoldCardProps> = ({
|
||||
children,
|
||||
hoverable = true,
|
||||
...props
|
||||
}) => (
|
||||
<Box
|
||||
bg={darkGoldTheme.bgCard}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={darkGoldTheme.border}
|
||||
p={3}
|
||||
transition="all 0.3s ease"
|
||||
_hover={
|
||||
hoverable
|
||||
? {
|
||||
bg: darkGoldTheme.bgCardHover,
|
||||
borderColor: darkGoldTheme.borderHover,
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default DarkGoldCard;
|
||||
@@ -0,0 +1,54 @@
|
||||
// 核心数值展示原子组件
|
||||
import React from 'react';
|
||||
import { HStack, Text } from '@chakra-ui/react';
|
||||
import { darkGoldTheme } from '../../../constants';
|
||||
|
||||
interface MetricValueProps {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
suffix?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: { label: 'xs', value: 'lg', suffix: 'sm' },
|
||||
md: { label: 'xs', value: 'xl', suffix: 'md' },
|
||||
lg: { label: 'xs', value: '2xl', suffix: 'md' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 核心数值展示组件 - 显示标签+数值
|
||||
*/
|
||||
const MetricValue: React.FC<MetricValueProps> = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
suffix,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
const sizes = sizeMap[size];
|
||||
|
||||
return (
|
||||
<HStack spacing={2} align="baseline">
|
||||
<Text color={darkGoldTheme.textMuted} fontSize={sizes.label}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
color={color}
|
||||
fontSize={sizes.value}
|
||||
fontWeight="bold"
|
||||
lineHeight="1"
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{suffix && (
|
||||
<Text color={color} fontSize={sizes.suffix} fontWeight="bold">
|
||||
{suffix}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricValue;
|
||||
@@ -0,0 +1,56 @@
|
||||
// 价格显示原子组件
|
||||
import React from 'react';
|
||||
import { HStack, Text, Icon } from '@chakra-ui/react';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
|
||||
interface PriceDisplayProps {
|
||||
price: number;
|
||||
changePercent: number;
|
||||
priceColor: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: { price: '2xl', percent: 'md', icon: 4 },
|
||||
md: { price: '3xl', percent: 'lg', icon: 5 },
|
||||
lg: { price: '4xl', percent: 'xl', icon: 6 },
|
||||
xl: { price: '5xl', percent: 'xl', icon: 6 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 价格显示组件 - 显示价格和涨跌幅
|
||||
*/
|
||||
const PriceDisplay: React.FC<PriceDisplayProps> = ({
|
||||
price,
|
||||
changePercent,
|
||||
priceColor,
|
||||
size = 'xl',
|
||||
}) => {
|
||||
const isUp = changePercent >= 0;
|
||||
const sizes = sizeMap[size];
|
||||
|
||||
return (
|
||||
<HStack spacing={4} align="baseline">
|
||||
<Text
|
||||
color={priceColor}
|
||||
fontSize={sizes.price}
|
||||
fontWeight="bold"
|
||||
lineHeight="1"
|
||||
>
|
||||
{price.toFixed(2)}
|
||||
</Text>
|
||||
<HStack spacing={1} align="center">
|
||||
<Icon
|
||||
as={isUp ? TrendingUp : TrendingDown}
|
||||
color={priceColor}
|
||||
boxSize={sizes.icon}
|
||||
/>
|
||||
<Text color={priceColor} fontSize={sizes.percent} fontWeight="bold">
|
||||
{isUp ? '+' : ''}{changePercent.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceDisplay;
|
||||
@@ -0,0 +1,24 @@
|
||||
// 状态标签原子组件
|
||||
import React from 'react';
|
||||
import { Text } from '@chakra-ui/react';
|
||||
|
||||
interface StatusTagProps {
|
||||
text: string;
|
||||
color: string;
|
||||
showParentheses?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态标签 - 显示如"活跃"、"健康"等状态文字
|
||||
*/
|
||||
const StatusTag: React.FC<StatusTagProps> = ({
|
||||
text,
|
||||
color,
|
||||
showParentheses = true,
|
||||
}) => (
|
||||
<Text color={color} fontWeight="medium" ml={1}>
|
||||
{showParentheses ? `(${text})` : text}
|
||||
</Text>
|
||||
);
|
||||
|
||||
export default StatusTag;
|
||||
@@ -0,0 +1,6 @@
|
||||
// 原子组件统一导出
|
||||
export { default as StatusTag } from './StatusTag';
|
||||
export { default as PriceDisplay } from './PriceDisplay';
|
||||
export { default as MetricValue } from './MetricValue';
|
||||
export { default as CardTitle } from './CardTitle';
|
||||
export { default as DarkGoldCard } from './DarkGoldCard';
|
||||
@@ -0,0 +1,114 @@
|
||||
// StockSummaryCard 主组件
|
||||
import React from 'react';
|
||||
import { SimpleGrid, HStack, Text, VStack } from '@chakra-ui/react';
|
||||
import { Flame, Coins, DollarSign, Shield } from 'lucide-react';
|
||||
import StockHeaderCard from './StockHeaderCard';
|
||||
import MetricCard from './MetricCard';
|
||||
import { StatusTag } from './atoms';
|
||||
import { getTurnoverStatus, getPEStatus, getPledgeStatus } from './utils';
|
||||
import { formatNumber, formatPercent } from '../../utils/formatUtils';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
import type { StockSummaryCardProps } from '../../types';
|
||||
|
||||
/**
|
||||
* 股票概览卡片组件
|
||||
* 4 列横向布局:股票信息 + 交易热度 + 估值安全 + 情绪风险
|
||||
*/
|
||||
const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary }) => {
|
||||
if (!summary) return null;
|
||||
|
||||
const { latest_trade, latest_funding, latest_pledge } = summary;
|
||||
|
||||
// 计算状态
|
||||
const turnoverStatus = latest_trade
|
||||
? getTurnoverStatus(latest_trade.turnover_rate)
|
||||
: { text: '-', color: darkGoldTheme.textMuted };
|
||||
|
||||
const peStatus = getPEStatus(latest_trade?.pe_ratio);
|
||||
|
||||
const pledgeStatus = latest_pledge
|
||||
? getPledgeStatus(latest_pledge.pledge_ratio)
|
||||
: { text: '-', color: darkGoldTheme.textMuted };
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
|
||||
{/* 卡片1: 股票信息 */}
|
||||
{latest_trade && (
|
||||
<StockHeaderCard
|
||||
stockName={summary.stock_name}
|
||||
stockCode={summary.stock_code}
|
||||
price={latest_trade.close}
|
||||
changePercent={latest_trade.change_percent}
|
||||
/>
|
||||
)}
|
||||
{/* 卡片1: 交易热度 */}
|
||||
<MetricCard
|
||||
title="交易热度"
|
||||
subtitle="流动性"
|
||||
leftIcon={<Flame size={14} />}
|
||||
rightIcon={<Coins size={14} />}
|
||||
mainLabel="成交额"
|
||||
mainValue={latest_trade ? formatNumber(latest_trade.amount) : '-'}
|
||||
mainColor={darkGoldTheme.orange}
|
||||
subText={
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Text>
|
||||
成交量 {latest_trade ? formatNumber(latest_trade.volume, 0) : '-'}
|
||||
</Text>
|
||||
<Text>|</Text>
|
||||
<Text>
|
||||
换手率 {latest_trade ? formatPercent(latest_trade.turnover_rate) : '-'}
|
||||
</Text>
|
||||
<StatusTag text={turnoverStatus.text} color={turnoverStatus.color} />
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 卡片2: 估值 VS 安全 */}
|
||||
<MetricCard
|
||||
title="估值 VS 安全"
|
||||
subtitle="便宜否"
|
||||
leftIcon={<DollarSign size={14} />}
|
||||
rightIcon={<Shield size={14} />}
|
||||
mainLabel="市盈率(PE)"
|
||||
mainValue={latest_trade?.pe_ratio?.toFixed(2) || '-'}
|
||||
mainColor={darkGoldTheme.orange}
|
||||
subText={
|
||||
<VStack align="start" spacing={0.5}>
|
||||
<Text color={peStatus.color} fontWeight="medium">
|
||||
{peStatus.text}
|
||||
</Text>
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Text>
|
||||
质押率 {latest_pledge ? formatPercent(latest_pledge.pledge_ratio) : '-'}
|
||||
</Text>
|
||||
<StatusTag text={pledgeStatus.text} color={pledgeStatus.color} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 卡片3: 情绪与风险 */}
|
||||
<MetricCard
|
||||
title="情绪与风险"
|
||||
subtitle="资金面"
|
||||
leftIcon={<Flame size={14} />}
|
||||
mainLabel="融资余额"
|
||||
mainValue={latest_funding ? formatNumber(latest_funding.financing_balance) : '-'}
|
||||
mainColor={darkGoldTheme.green}
|
||||
subText={
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text color={darkGoldTheme.textMuted}>(强调做多力量)</Text>
|
||||
<HStack spacing={1} flexWrap="wrap" mt={0.5}>
|
||||
<Text>
|
||||
融券 {latest_funding ? formatNumber(latest_funding.securities_balance) : '-'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockSummaryCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
// 状态计算工具函数
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
|
||||
export interface StatusResult {
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取走势简述
|
||||
*/
|
||||
export const getTrendDescription = (changePercent: number): string => {
|
||||
if (changePercent >= 5) return '强势上涨';
|
||||
if (changePercent >= 2) return '稳步上涨';
|
||||
if (changePercent > 0) return '小幅上涨';
|
||||
if (changePercent === 0) return '横盘整理';
|
||||
if (changePercent > -2) return '小幅下跌';
|
||||
if (changePercent > -5) return '震荡下跌';
|
||||
return '大幅下跌';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取换手率状态标签
|
||||
*/
|
||||
export const getTurnoverStatus = (rate: number): StatusResult => {
|
||||
if (rate >= 3) return { text: '活跃', color: darkGoldTheme.orange };
|
||||
if (rate >= 1) return { text: '正常', color: darkGoldTheme.gold };
|
||||
return { text: '冷清', color: darkGoldTheme.textMuted };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取市盈率估值标签
|
||||
*/
|
||||
export const getPEStatus = (pe: number | undefined): StatusResult => {
|
||||
if (!pe || pe <= 0) return { text: '亏损', color: darkGoldTheme.red };
|
||||
if (pe < 10) return { text: '极低估值 / 安全边际高', color: darkGoldTheme.green };
|
||||
if (pe < 20) return { text: '合理估值', color: darkGoldTheme.gold };
|
||||
if (pe < 40) return { text: '偏高估值', color: darkGoldTheme.orange };
|
||||
return { text: '高估值 / 泡沫风险', color: darkGoldTheme.red };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取质押率健康状态
|
||||
*/
|
||||
export const getPledgeStatus = (ratio: number): StatusResult => {
|
||||
if (ratio < 10) return { text: '健康', color: darkGoldTheme.green };
|
||||
if (ratio < 30) return { text: '正常', color: darkGoldTheme.gold };
|
||||
if (ratio < 50) return { text: '偏高', color: darkGoldTheme.orange };
|
||||
return { text: '警惕', color: darkGoldTheme.red };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取价格颜色
|
||||
*/
|
||||
export const getPriceColor = (changePercent: number): string => {
|
||||
return changePercent >= 0 ? darkGoldTheme.red : darkGoldTheme.green;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx
|
||||
// 大宗交易面板 - 大宗交易记录表格
|
||||
// 大宗交易面板 - 黑金主题
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
@@ -12,18 +12,15 @@ import {
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Center,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Tooltip,
|
||||
Heading,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import ThemedCard from '../ThemedCard';
|
||||
import { formatNumber } from '../../utils/formatUtils';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
import type { Theme, BigDealData } from '../../types';
|
||||
|
||||
export interface BigDealPanelProps {
|
||||
@@ -31,69 +28,116 @@ export interface BigDealPanelProps {
|
||||
bigDealData: BigDealData;
|
||||
}
|
||||
|
||||
const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
|
||||
// 黑金卡片样式
|
||||
const darkGoldCardStyle = {
|
||||
bg: darkGoldTheme.bgCard,
|
||||
border: '1px solid',
|
||||
borderColor: darkGoldTheme.border,
|
||||
borderRadius: 'xl',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: darkGoldTheme.borderHover,
|
||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金徽章样式
|
||||
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'gold' | 'orange' | 'green' | 'purple' }> = ({
|
||||
children,
|
||||
variant = 'gold',
|
||||
}) => {
|
||||
const colors = {
|
||||
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
|
||||
orange: { bg: 'rgba(255, 149, 0, 0.15)', color: darkGoldTheme.orange },
|
||||
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
|
||||
purple: { bg: 'rgba(160, 120, 220, 0.15)', color: '#A078DC' },
|
||||
};
|
||||
const style = colors[variant];
|
||||
|
||||
return (
|
||||
<ThemedCard theme={theme}>
|
||||
<CardHeader>
|
||||
<Heading size="md" color={theme.textSecondary}>
|
||||
<Box
|
||||
px={2}
|
||||
py={1}
|
||||
bg={style.bg}
|
||||
color={style.color}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
fontWeight="medium"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
|
||||
return (
|
||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
||||
<Heading size="md" color={darkGoldTheme.gold}>
|
||||
大宗交易记录
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
</Box>
|
||||
<Box p={4}>
|
||||
{bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<VStack spacing={4} align="stretch">
|
||||
{bigDealData.daily_stats.map((dayStats, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
bg={theme.bgDark}
|
||||
bg="rgba(212, 175, 55, 0.05)"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={theme.border}
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={theme.textSecondary}>
|
||||
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
|
||||
{dayStats.date}
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<Badge colorScheme="blue" fontSize="md">
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<DarkGoldBadge variant="gold">
|
||||
交易笔数: {dayStats.count}
|
||||
</Badge>
|
||||
<Badge colorScheme="green" fontSize="md">
|
||||
</DarkGoldBadge>
|
||||
<DarkGoldBadge variant="green">
|
||||
成交量: {formatNumber(dayStats.total_volume)}万股
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="md">
|
||||
</DarkGoldBadge>
|
||||
<DarkGoldBadge variant="orange">
|
||||
成交额: {formatNumber(dayStats.total_amount)}万元
|
||||
</Badge>
|
||||
<Badge colorScheme="purple" fontSize="md">
|
||||
</DarkGoldBadge>
|
||||
<DarkGoldBadge variant="purple">
|
||||
均价: {dayStats.avg_price?.toFixed(2) || '-'}元
|
||||
</Badge>
|
||||
</DarkGoldBadge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{dayStats.deals && dayStats.deals.length > 0 && (
|
||||
<TableContainer>
|
||||
<Table variant="simple" size="sm">
|
||||
<Table variant="unstyled" size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th color={theme.textSecondary}>买方营业部</Th>
|
||||
<Th color={theme.textSecondary}>卖方营业部</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Tr borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Th color={darkGoldTheme.textMuted} fontWeight="medium">买方营业部</Th>
|
||||
<Th color={darkGoldTheme.textMuted} fontWeight="medium">卖方营业部</Th>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
成交价
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
成交量(万股)
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
成交额(万元)
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{dayStats.deals.map((deal, i) => (
|
||||
<Tr key={i} _hover={{ bg: 'rgba(43, 108, 176, 0.05)' }}>
|
||||
<Tr
|
||||
key={i}
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.08)' }}
|
||||
borderBottom="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.1)"
|
||||
>
|
||||
<Td
|
||||
color={theme.textPrimary}
|
||||
color={darkGoldTheme.textSecondary}
|
||||
fontSize="xs"
|
||||
maxW="200px"
|
||||
isTruncated
|
||||
@@ -103,7 +147,7 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td
|
||||
color={theme.textPrimary}
|
||||
color={darkGoldTheme.textSecondary}
|
||||
fontSize="xs"
|
||||
maxW="200px"
|
||||
isTruncated
|
||||
@@ -112,13 +156,13 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
|
||||
<Text>{deal.seller_dept || '-'}</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
|
||||
<Td isNumeric color={darkGoldTheme.gold} fontWeight="bold">
|
||||
{deal.price?.toFixed(2) || '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
<Td isNumeric color={darkGoldTheme.textSecondary}>
|
||||
{deal.volume?.toFixed(2) || '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textSecondary} fontWeight="bold">
|
||||
<Td isNumeric color={darkGoldTheme.orange} fontWeight="bold">
|
||||
{deal.amount?.toFixed(2) || '-'}
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -132,11 +176,11 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="200px">
|
||||
<Text color={theme.textMuted}>暂无大宗交易数据</Text>
|
||||
<Text color={darkGoldTheme.textMuted}>暂无大宗交易数据</Text>
|
||||
</Center>
|
||||
)}
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx
|
||||
// 融资融券面板 - 融资融券数据图表和卡片
|
||||
// 融资融券面板 - 黑金主题
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Grid,
|
||||
@@ -14,9 +12,9 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import ThemedCard from '../ThemedCard';
|
||||
import { formatNumber } from '../../utils/formatUtils';
|
||||
import { getFundingOption } from '../../utils/chartOptions';
|
||||
import { getFundingDarkGoldOption } from '../../utils/chartOptions';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
import type { Theme, FundingDayData } from '../../types';
|
||||
|
||||
export interface FundingPanelProps {
|
||||
@@ -24,45 +22,73 @@ export interface FundingPanelProps {
|
||||
fundingData: FundingDayData[];
|
||||
}
|
||||
|
||||
const FundingPanel: React.FC<FundingPanelProps> = ({ theme, fundingData }) => {
|
||||
// 黑金卡片样式
|
||||
const darkGoldCardStyle = {
|
||||
bg: darkGoldTheme.bgCard,
|
||||
border: '1px solid',
|
||||
borderColor: darkGoldTheme.border,
|
||||
borderRadius: 'xl',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: darkGoldTheme.borderHover,
|
||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
};
|
||||
|
||||
const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<ThemedCard theme={theme}>
|
||||
<CardBody>
|
||||
{fundingData.length > 0 && (
|
||||
<Box h="400px">
|
||||
<ReactECharts
|
||||
option={getFundingOption(theme, fundingData)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="light"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
{/* 图表卡片 */}
|
||||
<Box {...darkGoldCardStyle} p={6}>
|
||||
{fundingData.length > 0 && (
|
||||
<Box h="400px">
|
||||
<ReactECharts
|
||||
option={getFundingDarkGoldOption(fundingData)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="dark"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
{/* 融资数据 */}
|
||||
<ThemedCard theme={theme}>
|
||||
<CardHeader>
|
||||
<Heading size="md" color={theme.success}>
|
||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
||||
<Heading size="md" color={darkGoldTheme.gold}>
|
||||
融资数据
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
</Box>
|
||||
<Box p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{fundingData
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((item, idx) => (
|
||||
<Box key={idx} p={3} bg="rgba(255, 68, 68, 0.05)" borderRadius="md">
|
||||
<Box
|
||||
key={idx}
|
||||
p={3}
|
||||
bg="rgba(212, 175, 55, 0.08)"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: 'rgba(212, 175, 55, 0.12)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
}}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text color={theme.textMuted}>{item.date}</Text>
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="sm">
|
||||
{item.date}
|
||||
</Text>
|
||||
<VStack align="end" spacing={0}>
|
||||
<Text color={theme.textPrimary} fontWeight="bold">
|
||||
<Text color={darkGoldTheme.gold} fontWeight="bold">
|
||||
{formatNumber(item.financing.balance)}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
买入{formatNumber(item.financing.buy)} / 偿还
|
||||
{formatNumber(item.financing.repay)}
|
||||
</Text>
|
||||
@@ -71,30 +97,44 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ theme, fundingData }) => {
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 融券数据 */}
|
||||
<ThemedCard theme={theme}>
|
||||
<CardHeader>
|
||||
<Heading size="md" color={theme.danger}>
|
||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
||||
<Heading size="md" color={darkGoldTheme.orange}>
|
||||
融券数据
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
</Box>
|
||||
<Box p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{fundingData
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((item, idx) => (
|
||||
<Box key={idx} p={3} bg="rgba(0, 200, 81, 0.05)" borderRadius="md">
|
||||
<Box
|
||||
key={idx}
|
||||
p={3}
|
||||
bg="rgba(255, 149, 0, 0.08)"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 149, 0, 0.15)"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 149, 0, 0.12)',
|
||||
borderColor: 'rgba(255, 149, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text color={theme.textMuted}>{item.date}</Text>
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="sm">
|
||||
{item.date}
|
||||
</Text>
|
||||
<VStack align="end" spacing={0}>
|
||||
<Text color={theme.textPrimary} fontWeight="bold">
|
||||
<Text color={darkGoldTheme.orange} fontWeight="bold">
|
||||
{formatNumber(item.securities.balance)}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={theme.textMuted}>
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
卖出{formatNumber(item.securities.sell)} / 偿还
|
||||
{formatNumber(item.securities.repay)}
|
||||
</Text>
|
||||
@@ -103,8 +143,8 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ theme, fundingData }) => {
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx
|
||||
// 股权质押面板 - 质押图表和表格
|
||||
// 股权质押面板 - 黑金主题
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
@@ -12,16 +12,14 @@ import {
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
Heading,
|
||||
} from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import ThemedCard from '../ThemedCard';
|
||||
import { formatNumber, formatPercent } from '../../utils/formatUtils';
|
||||
import { getPledgeOption } from '../../utils/chartOptions';
|
||||
import { getPledgeDarkGoldOption } from '../../utils/chartOptions';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
import type { Theme, PledgeData } from '../../types';
|
||||
|
||||
export interface PledgePanelProps {
|
||||
@@ -29,51 +27,65 @@ export interface PledgePanelProps {
|
||||
pledgeData: PledgeData[];
|
||||
}
|
||||
|
||||
const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
|
||||
// 黑金卡片样式
|
||||
const darkGoldCardStyle = {
|
||||
bg: darkGoldTheme.bgCard,
|
||||
border: '1px solid',
|
||||
borderColor: darkGoldTheme.border,
|
||||
borderRadius: 'xl',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: darkGoldTheme.borderHover,
|
||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<ThemedCard theme={theme}>
|
||||
<CardBody>
|
||||
{pledgeData.length > 0 && (
|
||||
<Box h="400px">
|
||||
<ReactECharts
|
||||
option={getPledgeOption(theme, pledgeData)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="light"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
{/* 图表卡片 */}
|
||||
<Box {...darkGoldCardStyle} p={6}>
|
||||
{pledgeData.length > 0 && (
|
||||
<Box h="400px">
|
||||
<ReactECharts
|
||||
option={getPledgeDarkGoldOption(pledgeData)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="dark"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ThemedCard theme={theme}>
|
||||
<CardHeader>
|
||||
<Heading size="md" color={theme.textSecondary}>
|
||||
{/* 质押明细表格 */}
|
||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
||||
<Heading size="md" color={darkGoldTheme.gold}>
|
||||
质押明细
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
</Box>
|
||||
<Box p={4}>
|
||||
<TableContainer>
|
||||
<Table variant="simple" size="sm">
|
||||
<Table variant="unstyled" size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th color={theme.textSecondary}>日期</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Tr borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Th color={darkGoldTheme.textMuted} fontWeight="medium">日期</Th>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
无限售质押(万股)
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
限售质押(万股)
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
质押总量(万股)
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
总股本(万股)
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
质押比例
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
|
||||
质押笔数
|
||||
</Th>
|
||||
</Tr>
|
||||
@@ -81,24 +93,29 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
|
||||
<Tbody>
|
||||
{pledgeData.length > 0 ? (
|
||||
pledgeData.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: theme.bgDark }}>
|
||||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
<Tr
|
||||
key={idx}
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.08)' }}
|
||||
borderBottom="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.1)"
|
||||
>
|
||||
<Td color={darkGoldTheme.textSecondary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={darkGoldTheme.textSecondary}>
|
||||
{formatNumber(item.unrestricted_pledge, 0)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
<Td isNumeric color={darkGoldTheme.textSecondary}>
|
||||
{formatNumber(item.restricted_pledge, 0)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
|
||||
<Td isNumeric color={darkGoldTheme.gold} fontWeight="bold">
|
||||
{formatNumber(item.total_pledge, 0)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
<Td isNumeric color={darkGoldTheme.textSecondary}>
|
||||
{formatNumber(item.total_shares, 0)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||||
<Td isNumeric color={darkGoldTheme.orange} fontWeight="bold">
|
||||
{formatPercent(item.pledge_ratio)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
<Td isNumeric color={darkGoldTheme.textSecondary}>
|
||||
{item.pledge_count}
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -106,7 +123,7 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
|
||||
) : (
|
||||
<Tr>
|
||||
<Td colSpan={7} textAlign="center" py={8}>
|
||||
<Text fontSize="sm" color={theme.textMuted}>
|
||||
<Text fontSize="sm" color={darkGoldTheme.textMuted}>
|
||||
暂无数据
|
||||
</Text>
|
||||
</Td>
|
||||
@@ -115,8 +132,8 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
</Box>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel.tsx
|
||||
// 交易数据面板 - K线图、分钟图、交易明细表格
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Spinner,
|
||||
Center,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Button,
|
||||
Grid,
|
||||
Icon,
|
||||
Heading,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
InfoIcon,
|
||||
RepeatIcon,
|
||||
TimeIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import ThemedCard from '../ThemedCard';
|
||||
import { formatNumber, formatPercent } from '../../utils/formatUtils';
|
||||
import { getKLineOption, getMinuteKLineOption } from '../../utils/chartOptions';
|
||||
import type { Theme, TradeDayData, MinuteData, RiseAnalysis } from '../../types';
|
||||
|
||||
export interface TradeDataPanelProps {
|
||||
theme: Theme;
|
||||
tradeData: TradeDayData[];
|
||||
minuteData: MinuteData | null;
|
||||
minuteLoading: boolean;
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
onLoadMinuteData: () => void;
|
||||
onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void;
|
||||
}
|
||||
|
||||
const TradeDataPanel: React.FC<TradeDataPanelProps> = ({
|
||||
theme,
|
||||
tradeData,
|
||||
minuteData,
|
||||
minuteLoading,
|
||||
analysisMap,
|
||||
onLoadMinuteData,
|
||||
onChartClick,
|
||||
}) => {
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* K线图 */}
|
||||
<ThemedCard theme={theme}>
|
||||
<CardBody>
|
||||
{tradeData.length > 0 && (
|
||||
<Box h="600px">
|
||||
<ReactECharts
|
||||
option={getKLineOption(theme, tradeData, analysisMap)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="light"
|
||||
onEvents={{ click: onChartClick }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
|
||||
{/* 分钟K线数据 */}
|
||||
<ThemedCard theme={theme}>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack spacing={3}>
|
||||
<Icon as={TimeIcon} color={theme.primary} boxSize={5} />
|
||||
<Heading size="md" color={theme.textSecondary}>
|
||||
当日分钟频数据
|
||||
</Heading>
|
||||
{minuteData && minuteData.trade_date && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
{minuteData.trade_date}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<Button
|
||||
leftIcon={<RepeatIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
onClick={onLoadMinuteData}
|
||||
isLoading={minuteLoading}
|
||||
loadingText="获取中"
|
||||
>
|
||||
获取分钟数据
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{minuteLoading ? (
|
||||
<Center h="400px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner
|
||||
thickness="4px"
|
||||
speed="0.65s"
|
||||
emptyColor={theme.bgDark}
|
||||
color={theme.primary}
|
||||
size="lg"
|
||||
/>
|
||||
<Text color={theme.textMuted} fontSize="sm">
|
||||
加载分钟频数据中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : minuteData && minuteData.data && minuteData.data.length > 0 ? (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box h="500px">
|
||||
<ReactECharts
|
||||
option={getMinuteKLineOption(theme, minuteData)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="light"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 分钟数据统计 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={ArrowUpIcon} boxSize={3} />
|
||||
<Text>开盘价</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.textPrimary} fontSize="lg">
|
||||
{minuteData.data[0]?.open?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={ArrowDownIcon} boxSize={3} />
|
||||
<Text>当前价</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber
|
||||
color={
|
||||
(minuteData.data[minuteData.data.length - 1]?.close || 0) >=
|
||||
(minuteData.data[0]?.open || 0)
|
||||
? theme.success
|
||||
: theme.danger
|
||||
}
|
||||
fontSize="lg"
|
||||
>
|
||||
{minuteData.data[minuteData.data.length - 1]?.close?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
<StatHelpText fontSize="xs">
|
||||
<StatArrow
|
||||
type={
|
||||
(minuteData.data[minuteData.data.length - 1]?.close || 0) >=
|
||||
(minuteData.data[0]?.open || 0)
|
||||
? 'increase'
|
||||
: 'decrease'
|
||||
}
|
||||
/>
|
||||
{(() => {
|
||||
const lastClose = minuteData.data[minuteData.data.length - 1]?.close;
|
||||
const firstOpen = minuteData.data[0]?.open;
|
||||
if (lastClose && firstOpen) {
|
||||
return Math.abs(((lastClose - firstOpen) / firstOpen) * 100).toFixed(2);
|
||||
}
|
||||
return '0.00';
|
||||
})()}
|
||||
%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={ChevronUpIcon} boxSize={3} />
|
||||
<Text>最高价</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.success} fontSize="lg">
|
||||
{Math.max(...minuteData.data.map((item) => item.high).filter(Boolean)).toFixed(
|
||||
2
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel color={theme.textMuted} fontSize="sm">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={ChevronDownIcon} boxSize={3} />
|
||||
<Text>最低价</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.danger} fontSize="lg">
|
||||
{Math.min(...minuteData.data.map((item) => item.low).filter(Boolean)).toFixed(2)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 成交数据分析 */}
|
||||
<Box
|
||||
p={4}
|
||||
bg={theme.bgDark}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontWeight="bold" color={theme.textSecondary}>
|
||||
成交数据分析
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
总成交量:{' '}
|
||||
{formatNumber(
|
||||
minuteData.data.reduce((sum, item) => sum + item.volume, 0),
|
||||
0
|
||||
)}
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
总成交额:{' '}
|
||||
{formatNumber(minuteData.data.reduce((sum, item) => sum + item.amount, 0))}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Grid templateColumns="repeat(3, 1fr)" gap={4}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color={theme.textMuted} mb={2}>
|
||||
活跃时段
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.textPrimary}>
|
||||
{(() => {
|
||||
const maxVolume = Math.max(...minuteData.data.map((item) => item.volume));
|
||||
const activeTime = minuteData.data.find(
|
||||
(item) => item.volume === maxVolume
|
||||
);
|
||||
return activeTime
|
||||
? `${activeTime.time} (${formatNumber(maxVolume, 0)})`
|
||||
: '-';
|
||||
})()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color={theme.textMuted} mb={2}>
|
||||
平均价格
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.textPrimary}>
|
||||
{(
|
||||
minuteData.data.reduce((sum, item) => sum + item.close, 0) /
|
||||
minuteData.data.length
|
||||
).toFixed(2)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color={theme.textMuted} mb={2}>
|
||||
数据点数
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.textPrimary}>
|
||||
{minuteData.data.length} 个分钟
|
||||
</Text>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Box>
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<Icon as={InfoIcon} color={theme.textMuted} boxSize={12} />
|
||||
<VStack spacing={2}>
|
||||
<Text color={theme.textMuted} fontSize="lg">
|
||||
暂无分钟频数据
|
||||
</Text>
|
||||
<Text color={theme.textMuted} fontSize="sm" textAlign="center">
|
||||
点击"获取分钟数据"按钮加载最新的交易日分钟频数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
|
||||
{/* 交易明细表格 */}
|
||||
<ThemedCard theme={theme}>
|
||||
<CardHeader>
|
||||
<Heading size="md" color={theme.textSecondary}>
|
||||
交易明细
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TableContainer>
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th color={theme.textSecondary}>日期</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
开盘
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
最高
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
最低
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
收盘
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
涨跌幅
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
成交量
|
||||
</Th>
|
||||
<Th isNumeric color={theme.textSecondary}>
|
||||
成交额
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{tradeData
|
||||
.slice(-10)
|
||||
.reverse()
|
||||
.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: theme.bgDark }}>
|
||||
<Td color={theme.textPrimary}>{item.date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{item.open}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{item.high}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{item.low}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
|
||||
{item.close}
|
||||
</Td>
|
||||
<Td
|
||||
isNumeric
|
||||
color={item.change_percent >= 0 ? theme.success : theme.danger}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{item.change_percent >= 0 ? '+' : ''}
|
||||
{formatPercent(item.change_percent)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{formatNumber(item.volume, 0)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{formatNumber(item.amount)}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeDataPanel;
|
||||
@@ -0,0 +1,242 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx
|
||||
// K线模块 - 日K线/分钟K线切换展示(黑金主题)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
Icon,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
import { RepeatIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import { BarChart2, Clock, TrendingUp, Calendar } from 'lucide-react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import { darkGoldTheme, PERIOD_OPTIONS } from '../../../constants';
|
||||
import { getKLineDarkGoldOption, getMinuteKLineDarkGoldOption } from '../../../utils/chartOptions';
|
||||
import type { KLineModuleProps } from '../../../types';
|
||||
|
||||
// 空状态组件(内联)
|
||||
const EmptyState: React.FC<{ title: string; description: string }> = ({ title, description }) => (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<Icon as={InfoIcon} color={darkGoldTheme.textMuted} boxSize={12} />
|
||||
<VStack spacing={2}>
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="lg">{title}</Text>
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="sm" textAlign="center">{description}</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
// 重新导出类型供外部使用
|
||||
export type { KLineModuleProps } from '../../../types';
|
||||
|
||||
type ChartMode = 'daily' | 'minute';
|
||||
|
||||
const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
theme,
|
||||
tradeData,
|
||||
minuteData,
|
||||
minuteLoading,
|
||||
analysisMap,
|
||||
onLoadMinuteData,
|
||||
onChartClick,
|
||||
selectedPeriod,
|
||||
onPeriodChange,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<ChartMode>('daily');
|
||||
const hasMinuteData = minuteData && minuteData.data && minuteData.data.length > 0;
|
||||
|
||||
// 切换到分钟模式时自动加载数据
|
||||
const handleModeChange = (newMode: ChartMode) => {
|
||||
setMode(newMode);
|
||||
if (newMode === 'minute' && !hasMinuteData && !minuteLoading) {
|
||||
onLoadMinuteData();
|
||||
}
|
||||
};
|
||||
|
||||
// 黑金主题按钮样式
|
||||
const activeButtonStyle = {
|
||||
bg: `linear-gradient(135deg, ${darkGoldTheme.gold} 0%, ${darkGoldTheme.orange} 100%)`,
|
||||
color: '#1a1a2e',
|
||||
borderColor: darkGoldTheme.gold,
|
||||
_hover: {
|
||||
bg: `linear-gradient(135deg, ${darkGoldTheme.goldLight} 0%, ${darkGoldTheme.gold} 100%)`,
|
||||
},
|
||||
};
|
||||
|
||||
const inactiveButtonStyle = {
|
||||
bg: 'transparent',
|
||||
color: darkGoldTheme.textMuted,
|
||||
borderColor: darkGoldTheme.border,
|
||||
_hover: {
|
||||
bg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: darkGoldTheme.gold,
|
||||
color: darkGoldTheme.gold,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="transparent"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<Box py={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack spacing={3}>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="lg"
|
||||
bg={darkGoldTheme.tagBg}
|
||||
>
|
||||
<TrendingUp size={20} color={darkGoldTheme.gold} />
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
bgGradient={`linear(to-r, ${darkGoldTheme.gold}, ${darkGoldTheme.orange})`}
|
||||
bgClip="text"
|
||||
>
|
||||
{mode === 'daily' ? '日K线图' : '分钟K线图'}
|
||||
</Text>
|
||||
{mode === 'minute' && minuteData?.trade_date && (
|
||||
<Badge
|
||||
bg={darkGoldTheme.tagBg}
|
||||
color={darkGoldTheme.gold}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{minuteData.trade_date}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={3}>
|
||||
{/* 日K模式下显示时间范围选择器 */}
|
||||
{mode === 'daily' && onPeriodChange && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Calendar} boxSize={4} color={darkGoldTheme.textMuted} />
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => onPeriodChange(Number(e.target.value))}
|
||||
bg="transparent"
|
||||
borderColor={darkGoldTheme.border}
|
||||
color={darkGoldTheme.textPrimary}
|
||||
maxW="100px"
|
||||
_hover={{ borderColor: darkGoldTheme.gold }}
|
||||
_focus={{ borderColor: darkGoldTheme.gold, boxShadow: 'none' }}
|
||||
sx={{
|
||||
option: {
|
||||
background: '#1a1a2e',
|
||||
color: darkGoldTheme.textPrimary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 分钟模式下的刷新按钮 */}
|
||||
{mode === 'minute' && (
|
||||
<Button
|
||||
leftIcon={<RepeatIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onLoadMinuteData}
|
||||
isLoading={minuteLoading}
|
||||
loadingText="获取中"
|
||||
{...inactiveButtonStyle}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 模式切换按钮组 */}
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
leftIcon={<BarChart2 size={14} />}
|
||||
onClick={() => handleModeChange('daily')}
|
||||
{...(mode === 'daily' ? activeButtonStyle : inactiveButtonStyle)}
|
||||
>
|
||||
日K
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<Clock size={14} />}
|
||||
onClick={() => handleModeChange('minute')}
|
||||
{...(mode === 'minute' ? activeButtonStyle : inactiveButtonStyle)}
|
||||
>
|
||||
分钟
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 卡片内容 */}
|
||||
<Box pt={4}>
|
||||
{mode === 'daily' ? (
|
||||
// 日K线图
|
||||
tradeData.length > 0 ? (
|
||||
<Box h="600px">
|
||||
<ReactECharts
|
||||
option={getKLineDarkGoldOption(tradeData, analysisMap)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="dark"
|
||||
onEvents={{ click: onChartClick }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<EmptyState title="暂无日K线数据" description="该股票暂无交易数据" />
|
||||
)
|
||||
) : (
|
||||
// 分钟K线图
|
||||
minuteLoading ? (
|
||||
<Center h="500px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner
|
||||
thickness="4px"
|
||||
speed="0.65s"
|
||||
emptyColor="rgba(212, 175, 55, 0.2)"
|
||||
color={darkGoldTheme.gold}
|
||||
size="lg"
|
||||
/>
|
||||
<Text color={darkGoldTheme.textMuted} fontSize="sm">
|
||||
加载分钟频数据中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : hasMinuteData ? (
|
||||
<Box h="500px">
|
||||
<ReactECharts
|
||||
option={getMinuteKLineDarkGoldOption(minuteData)}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="dark"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<EmptyState title="暂无分钟数据" description="点击刷新按钮获取当日分钟频数据" />
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default KLineModule;
|
||||
@@ -0,0 +1,51 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/index.tsx
|
||||
// 交易数据面板 - K线模块(日K/分钟切换)
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import KLineModule from './KLineModule';
|
||||
import type { Theme, TradeDayData, MinuteData, RiseAnalysis } from '../../../types';
|
||||
|
||||
export interface TradeDataPanelProps {
|
||||
theme: Theme;
|
||||
tradeData: TradeDayData[];
|
||||
minuteData: MinuteData | null;
|
||||
minuteLoading: boolean;
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
onLoadMinuteData: () => void;
|
||||
onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void;
|
||||
selectedPeriod?: number;
|
||||
onPeriodChange?: (period: number) => void;
|
||||
}
|
||||
|
||||
const TradeDataPanel: React.FC<TradeDataPanelProps> = ({
|
||||
theme,
|
||||
tradeData,
|
||||
minuteData,
|
||||
minuteLoading,
|
||||
analysisMap,
|
||||
onLoadMinuteData,
|
||||
onChartClick,
|
||||
selectedPeriod,
|
||||
onPeriodChange,
|
||||
}) => {
|
||||
return (
|
||||
<KLineModule
|
||||
theme={theme}
|
||||
tradeData={tradeData}
|
||||
minuteData={minuteData}
|
||||
minuteLoading={minuteLoading}
|
||||
analysisMap={analysisMap}
|
||||
onLoadMinuteData={onLoadMinuteData}
|
||||
onChartClick={onChartClick}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={onPeriodChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeDataPanel;
|
||||
|
||||
// 导出子组件供外部按需使用
|
||||
export { default as KLineModule } from './KLineModule';
|
||||
export type { KLineModuleProps } from './KLineModule';
|
||||
@@ -1,22 +1,19 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx
|
||||
// 龙虎榜面板 - 龙虎榜数据展示
|
||||
// 龙虎榜面板 - 黑金主题
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Center,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Grid,
|
||||
Heading,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import ThemedCard from '../ThemedCard';
|
||||
import { formatNumber } from '../../utils/formatUtils';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
import type { Theme, UnusualData } from '../../types';
|
||||
|
||||
export interface UnusualPanelProps {
|
||||
@@ -24,49 +21,87 @@ export interface UnusualPanelProps {
|
||||
unusualData: UnusualData;
|
||||
}
|
||||
|
||||
const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
|
||||
// 黑金卡片样式
|
||||
const darkGoldCardStyle = {
|
||||
bg: darkGoldTheme.bgCard,
|
||||
border: '1px solid',
|
||||
borderColor: darkGoldTheme.border,
|
||||
borderRadius: 'xl',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: darkGoldTheme.borderHover,
|
||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金徽章样式
|
||||
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'red' | 'green' | 'gold' }> = ({
|
||||
children,
|
||||
variant = 'gold',
|
||||
}) => {
|
||||
const colors = {
|
||||
red: { bg: 'rgba(255, 68, 68, 0.15)', color: darkGoldTheme.red },
|
||||
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
|
||||
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
|
||||
};
|
||||
const style = colors[variant];
|
||||
|
||||
return (
|
||||
<ThemedCard theme={theme}>
|
||||
<CardHeader>
|
||||
<Heading size="md" color={theme.textSecondary}>
|
||||
<Box
|
||||
px={2}
|
||||
py={1}
|
||||
bg={style.bg}
|
||||
color={style.color}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
fontWeight="medium"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
|
||||
return (
|
||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
||||
<Heading size="md" color={darkGoldTheme.gold}>
|
||||
龙虎榜数据
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
</Box>
|
||||
<Box p={4}>
|
||||
{unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<VStack spacing={4} align="stretch">
|
||||
{unusualData.grouped_data.map((dayData, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
bg={theme.bgDark}
|
||||
bg="rgba(212, 175, 55, 0.05)"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={theme.border}
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={theme.textSecondary}>
|
||||
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
|
||||
{dayData.date}
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<Badge colorScheme="red" fontSize="md">
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<DarkGoldBadge variant="red">
|
||||
买入: {formatNumber(dayData.total_buy)}
|
||||
</Badge>
|
||||
<Badge colorScheme="green" fontSize="md">
|
||||
</DarkGoldBadge>
|
||||
<DarkGoldBadge variant="green">
|
||||
卖出: {formatNumber(dayData.total_sell)}
|
||||
</Badge>
|
||||
<Badge
|
||||
colorScheme={dayData.net_amount > 0 ? 'red' : 'green'}
|
||||
fontSize="md"
|
||||
>
|
||||
</DarkGoldBadge>
|
||||
<DarkGoldBadge variant={dayData.net_amount > 0 ? 'red' : 'green'}>
|
||||
净额: {formatNumber(dayData.net_amount)}
|
||||
</Badge>
|
||||
</DarkGoldBadge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
|
||||
<Box>
|
||||
<Text fontWeight="bold" color={theme.success} mb={2}>
|
||||
<Text fontWeight="bold" color={darkGoldTheme.red} mb={2} fontSize="sm">
|
||||
买入前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
@@ -76,24 +111,31 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg="rgba(255, 68, 68, 0.05)"
|
||||
bg="rgba(255, 68, 68, 0.08)"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 68, 68, 0.15)"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 68, 68, 0.12)',
|
||||
borderColor: 'rgba(255, 68, 68, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={theme.textPrimary}
|
||||
fontSize="xs"
|
||||
color={darkGoldTheme.textSecondary}
|
||||
isTruncated
|
||||
maxW="70%"
|
||||
>
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||||
<Text fontSize="xs" color={darkGoldTheme.red} fontWeight="bold">
|
||||
{formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
@@ -101,7 +143,7 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontWeight="bold" color={theme.danger} mb={2}>
|
||||
<Text fontWeight="bold" color={darkGoldTheme.green} mb={2} fontSize="sm">
|
||||
卖出前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
@@ -111,24 +153,31 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg="rgba(0, 200, 81, 0.05)"
|
||||
bg="rgba(0, 200, 81, 0.08)"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="rgba(0, 200, 81, 0.15)"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: 'rgba(0, 200, 81, 0.12)',
|
||||
borderColor: 'rgba(0, 200, 81, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={theme.textPrimary}
|
||||
fontSize="xs"
|
||||
color={darkGoldTheme.textSecondary}
|
||||
isTruncated
|
||||
maxW="70%"
|
||||
>
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||||
<Text fontSize="xs" color={darkGoldTheme.green} fontWeight="bold">
|
||||
{formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
@@ -137,14 +186,22 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
|
||||
</Grid>
|
||||
|
||||
{/* 信息类型标签 */}
|
||||
<HStack mt={3} spacing={2}>
|
||||
<Text fontSize="sm" color={theme.textMuted}>
|
||||
<HStack mt={3} spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
类型:
|
||||
</Text>
|
||||
{dayData.info_types?.map((type, i) => (
|
||||
<Badge key={i} colorScheme="blue" fontSize="xs">
|
||||
<Box
|
||||
key={i}
|
||||
px={2}
|
||||
py={0.5}
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
color={darkGoldTheme.gold}
|
||||
borderRadius="sm"
|
||||
fontSize="xs"
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
@@ -152,11 +209,11 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="200px">
|
||||
<Text color={theme.textMuted}>暂无龙虎榜数据</Text>
|
||||
<Text color={darkGoldTheme.textMuted}>暂无龙虎榜数据</Text>
|
||||
</Center>
|
||||
)}
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,3 +13,7 @@ export type { FundingPanelProps } from './FundingPanel';
|
||||
export type { BigDealPanelProps } from './BigDealPanel';
|
||||
export type { UnusualPanelProps } from './UnusualPanel';
|
||||
export type { PledgePanelProps } from './PledgePanel';
|
||||
|
||||
// 导出 TradeDataPanel 子组件
|
||||
export { KLineModule } from './TradeDataPanel';
|
||||
export type { KLineModuleProps } from './TradeDataPanel';
|
||||
|
||||
@@ -28,6 +28,35 @@ export const themes: Record<'light', Theme> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 黑金主题配置 - 用于 StockSummaryCard
|
||||
*/
|
||||
export const darkGoldTheme = {
|
||||
// 背景
|
||||
bgCard: 'linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)',
|
||||
bgCardHover: 'linear-gradient(135deg, #252540 0%, #1a1a2e 100%)',
|
||||
|
||||
// 边框
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
borderHover: 'rgba(212, 175, 55, 0.6)',
|
||||
|
||||
// 文字
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.85)',
|
||||
textMuted: 'rgba(255, 255, 255, 0.6)',
|
||||
|
||||
// 强调色
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F4D03F',
|
||||
orange: '#FF9500',
|
||||
green: '#00C851',
|
||||
red: '#FF4444',
|
||||
|
||||
// 标签背景
|
||||
tagBg: 'rgba(212, 175, 55, 0.15)',
|
||||
tagText: '#D4AF37',
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认股票代码
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/MarketDataView/hooks/useMarketData.ts
|
||||
// MarketDataView 数据获取 Hook
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { marketService } from '../services/marketService';
|
||||
import { DEFAULT_PERIOD } from '../constants';
|
||||
@@ -28,6 +28,7 @@ export const useMarketData = (
|
||||
): UseMarketDataReturn => {
|
||||
// 主数据状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tradeLoading, setTradeLoading] = useState(false);
|
||||
const [summary, setSummary] = useState<MarketSummary | null>(null);
|
||||
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
|
||||
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
|
||||
@@ -40,6 +41,13 @@ export const useMarketData = (
|
||||
const [minuteData, setMinuteData] = useState<MinuteData | null>(null);
|
||||
const [minuteLoading, setMinuteLoading] = useState(false);
|
||||
|
||||
// 记录是否已完成首次加载
|
||||
const isInitializedRef = useRef(false);
|
||||
// 记录上一次的 stockCode,用于判断是否需要重新加载所有数据
|
||||
const prevStockCodeRef = useRef(stockCode);
|
||||
// 记录上一次的 period,用于判断是否需要刷新交易数据
|
||||
const prevPeriodRef = useRef(period);
|
||||
|
||||
/**
|
||||
* 加载所有市场数据
|
||||
*/
|
||||
@@ -159,6 +167,50 @@ export const useMarketData = (
|
||||
}
|
||||
}, [stockCode]);
|
||||
|
||||
/**
|
||||
* 单独刷新日K线数据(只刷新交易数据和涨幅分析)
|
||||
* 用于切换时间周期时,避免重新加载所有数据
|
||||
*/
|
||||
const refreshTradeData = useCallback(async () => {
|
||||
if (!stockCode) return;
|
||||
|
||||
logger.debug('useMarketData', '刷新日K线数据', { stockCode, period });
|
||||
setTradeLoading(true);
|
||||
|
||||
try {
|
||||
// 并行获取交易数据和涨幅分析
|
||||
const [tradeRes, riseAnalysisRes] = await Promise.all([
|
||||
marketService.getTradeData(stockCode, period),
|
||||
marketService.getRiseAnalysis(stockCode),
|
||||
]);
|
||||
|
||||
// 更新交易数据
|
||||
if (tradeRes.success && tradeRes.data) {
|
||||
setTradeData(tradeRes.data);
|
||||
|
||||
// 重建涨幅分析映射
|
||||
if (riseAnalysisRes.success && riseAnalysisRes.data) {
|
||||
const tempAnalysisMap: Record<number, RiseAnalysis> = {};
|
||||
riseAnalysisRes.data.forEach((analysis) => {
|
||||
const dateIndex = tradeRes.data.findIndex(
|
||||
(item) => item.date.substring(0, 10) === analysis.trade_date
|
||||
);
|
||||
if (dateIndex !== -1) {
|
||||
tempAnalysisMap[dateIndex] = analysis;
|
||||
}
|
||||
});
|
||||
setAnalysisMap(tempAnalysisMap);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('useMarketData', '日K线数据刷新成功', { stockCode, period });
|
||||
} catch (error) {
|
||||
logger.error('useMarketData', 'refreshTradeData', error, { stockCode, period });
|
||||
} finally {
|
||||
setTradeLoading(false);
|
||||
}
|
||||
}, [stockCode, period]);
|
||||
|
||||
/**
|
||||
* 刷新所有数据
|
||||
*/
|
||||
@@ -166,16 +218,32 @@ export const useMarketData = (
|
||||
await Promise.all([loadMarketData(), loadMinuteData()]);
|
||||
}, [loadMarketData, loadMinuteData]);
|
||||
|
||||
// 监听股票代码和周期变化,自动加载数据
|
||||
// 监听股票代码变化,加载所有数据(首次加载或切换股票)
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
loadMarketData();
|
||||
loadMinuteData();
|
||||
// stockCode 变化时,加载所有数据
|
||||
if (stockCode !== prevStockCodeRef.current || !isInitializedRef.current) {
|
||||
loadMarketData();
|
||||
loadMinuteData();
|
||||
prevStockCodeRef.current = stockCode;
|
||||
prevPeriodRef.current = period; // 同步重置 period ref,避免切换股票后误触发 refreshTradeData
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [stockCode, period, loadMarketData, loadMinuteData]);
|
||||
|
||||
// 监听时间周期变化,只刷新日K线数据
|
||||
useEffect(() => {
|
||||
// 只有在已初始化后,且 period 真正变化时才单独刷新交易数据
|
||||
if (stockCode && isInitializedRef.current && period !== prevPeriodRef.current) {
|
||||
refreshTradeData();
|
||||
prevPeriodRef.current = period;
|
||||
}
|
||||
}, [period, refreshTradeData, stockCode]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
tradeLoading,
|
||||
summary,
|
||||
tradeData,
|
||||
fundingData,
|
||||
@@ -187,6 +255,7 @@ export const useMarketData = (
|
||||
analysisMap,
|
||||
refetch,
|
||||
loadMinuteData,
|
||||
refreshTradeData,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
// src/views/Company/components/MarketDataView/index.tsx
|
||||
// MarketDataView 主组件 - 股票市场数据综合展示
|
||||
|
||||
import React, { useState, useEffect, ReactNode } from 'react';
|
||||
import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Text,
|
||||
CardBody,
|
||||
Spinner,
|
||||
Center,
|
||||
VStack,
|
||||
HStack,
|
||||
Select,
|
||||
Button,
|
||||
Icon,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChevronUpIcon,
|
||||
RepeatIcon,
|
||||
ArrowUpIcon,
|
||||
StarIcon,
|
||||
LockIcon,
|
||||
UnlockIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
Unlock,
|
||||
ArrowUp,
|
||||
Star,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 通用组件
|
||||
import SubTabContainer from '@components/SubTabContainer';
|
||||
import type { SubTabConfig } from '@components/SubTabContainer';
|
||||
|
||||
// 内部模块导入
|
||||
import { themes, DEFAULT_PERIOD, PERIOD_OPTIONS } from './constants';
|
||||
import { themes, DEFAULT_PERIOD } from './constants';
|
||||
import { useMarketData } from './hooks/useMarketData';
|
||||
import {
|
||||
ThemedCard,
|
||||
@@ -88,26 +81,85 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
// 处理图表点击事件
|
||||
const handleChartClick = (params: { seriesName?: string; data?: [number, number] }) => {
|
||||
if (params.seriesName === '涨幅分析' && params.data) {
|
||||
const dataIndex = params.data[0];
|
||||
const analysis = analysisMap[dataIndex];
|
||||
const handleChartClick = useCallback(
|
||||
(params: { seriesName?: string; data?: [number, number] }) => {
|
||||
if (params.seriesName === '涨幅分析' && params.data) {
|
||||
const dataIndex = params.data[0];
|
||||
const analysis = analysisMap[dataIndex];
|
||||
|
||||
if (analysis) {
|
||||
setModalContent(<AnalysisContent analysis={analysis} theme={theme} />);
|
||||
onOpen();
|
||||
if (analysis) {
|
||||
setModalContent(<AnalysisContent analysis={analysis} theme={theme} />);
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[analysisMap, theme, onOpen]
|
||||
);
|
||||
|
||||
// Tab 配置 - 使用通用 SubTabContainer(不含交易数据,交易数据单独显示在上方)
|
||||
const tabConfigs: SubTabConfig[] = [
|
||||
{ key: 'funding', name: '融资融券', icon: Unlock, component: FundingPanel },
|
||||
{ key: 'bigDeal', name: '大宗交易', icon: ArrowUp, component: BigDealPanel },
|
||||
{ key: 'unusual', name: '龙虎榜', icon: Star, component: UnusualPanel },
|
||||
{ key: 'pledge', name: '股权质押', icon: Lock, component: PledgePanel },
|
||||
];
|
||||
|
||||
// 传递给 Tab 组件的 props
|
||||
const componentProps = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
tradeData,
|
||||
minuteData,
|
||||
minuteLoading,
|
||||
analysisMap,
|
||||
onLoadMinuteData: loadMinuteData,
|
||||
onChartClick: handleChartClick,
|
||||
selectedPeriod,
|
||||
onPeriodChange: setSelectedPeriod,
|
||||
fundingData,
|
||||
bigDealData,
|
||||
unusualData,
|
||||
pledgeData,
|
||||
}),
|
||||
[
|
||||
theme,
|
||||
tradeData,
|
||||
minuteData,
|
||||
minuteLoading,
|
||||
analysisMap,
|
||||
loadMinuteData,
|
||||
handleChartClick,
|
||||
selectedPeriod,
|
||||
fundingData,
|
||||
bigDealData,
|
||||
unusualData,
|
||||
pledgeData,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box bg={theme.bgMain} minH="100vh" color={theme.textPrimary}>
|
||||
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
|
||||
<Container maxW="container.xl" py={6}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{/* 股票概览 */}
|
||||
{summary && <StockSummaryCard summary={summary} theme={theme} />}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
{/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */}
|
||||
{!loading && (
|
||||
<TradeDataPanel
|
||||
theme={theme}
|
||||
tradeData={tradeData}
|
||||
minuteData={minuteData}
|
||||
minuteLoading={minuteLoading}
|
||||
analysisMap={analysisMap}
|
||||
onLoadMinuteData={loadMinuteData}
|
||||
onChartClick={handleChartClick}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 - Tab */}
|
||||
{loading ? (
|
||||
<ThemedCard theme={theme}>
|
||||
<CardBody>
|
||||
@@ -126,152 +178,14 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
</CardBody>
|
||||
</ThemedCard>
|
||||
) : (
|
||||
<Tabs
|
||||
variant="soft-rounded"
|
||||
colorScheme="blue"
|
||||
<SubTabContainer
|
||||
tabs={tabConfigs}
|
||||
componentProps={componentProps}
|
||||
themePreset="blackGold"
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
>
|
||||
{/* Tab 导航栏 */}
|
||||
<Box
|
||||
bg={theme.bgCard}
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<HStack justify="space-between" align="center" spacing={4}>
|
||||
<TabList overflowX="auto" border="none" flex="1">
|
||||
<Tab
|
||||
color={theme.textMuted}
|
||||
_selected={{ color: 'white', bg: theme.primary }}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={ChevronUpIcon} boxSize={4} />
|
||||
<Text>交易数据</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
color={theme.textMuted}
|
||||
_selected={{ color: 'white', bg: theme.primary }}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={UnlockIcon} boxSize={4} />
|
||||
<Text>融资融券</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
color={theme.textMuted}
|
||||
_selected={{ color: 'white', bg: theme.primary }}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={ArrowUpIcon} boxSize={4} />
|
||||
<Text>大宗交易</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
color={theme.textMuted}
|
||||
_selected={{ color: 'white', bg: theme.primary }}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={StarIcon} boxSize={4} />
|
||||
<Text>龙虎榜</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
<Tab
|
||||
color={theme.textMuted}
|
||||
_selected={{ color: 'white', bg: theme.primary }}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={LockIcon} boxSize={4} />
|
||||
<Text>股权质押</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
{/* 时间范围选择和刷新按钮 */}
|
||||
<HStack spacing={2} flexShrink={0} ml="auto">
|
||||
<Text color={theme.textPrimary} whiteSpace="nowrap" fontSize="sm">
|
||||
时间范围:
|
||||
</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||
bg={theme.bgDark}
|
||||
borderColor={theme.border}
|
||||
color={theme.textPrimary}
|
||||
maxW="120px"
|
||||
>
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
style={{ background: theme.bgDark }}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
leftIcon={<RepeatIcon />}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
onClick={refetch}
|
||||
isLoading={loading}
|
||||
size="sm"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<TabPanels>
|
||||
{/* 交易数据 Tab */}
|
||||
<TabPanel px={0}>
|
||||
<TradeDataPanel
|
||||
theme={theme}
|
||||
tradeData={tradeData}
|
||||
minuteData={minuteData}
|
||||
minuteLoading={minuteLoading}
|
||||
analysisMap={analysisMap}
|
||||
onLoadMinuteData={loadMinuteData}
|
||||
onChartClick={handleChartClick}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* 融资融券 Tab */}
|
||||
<TabPanel px={0}>
|
||||
<FundingPanel theme={theme} fundingData={fundingData} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 大宗交易 Tab */}
|
||||
<TabPanel px={0}>
|
||||
<BigDealPanel theme={theme} bigDealData={bigDealData} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 龙虎榜 Tab */}
|
||||
<TabPanel px={0}>
|
||||
<UnusualPanel theme={theme} unusualData={unusualData} />
|
||||
</TabPanel>
|
||||
|
||||
{/* 股权质押 Tab */}
|
||||
<TabPanel px={0}>
|
||||
<PledgePanel theme={theme} pledgeData={pledgeData} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
onTabChange={(index) => setActiveTab(index)}
|
||||
isLazy
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
@@ -270,7 +270,7 @@ export interface MarkdownRendererProps {
|
||||
*/
|
||||
export interface StockSummaryCardProps {
|
||||
summary: MarketSummary;
|
||||
theme: Theme;
|
||||
theme?: Theme; // 可选,StockSummaryCard 使用内置黑金主题
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,31 +287,18 @@ export interface TradeDataTabProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* KLineChart 组件 Props
|
||||
* KLineModule 组件 Props(日K/分钟K线切换模块)
|
||||
*/
|
||||
export interface KLineChartProps {
|
||||
export interface KLineModuleProps {
|
||||
theme: Theme;
|
||||
tradeData: TradeDayData[];
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
onAnalysisClick: (analysis: RiseAnalysis) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MinuteKLineChart 组件 Props
|
||||
*/
|
||||
export interface MinuteKLineChartProps {
|
||||
theme: Theme;
|
||||
minuteData: MinuteData | null;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TradeTable 组件 Props
|
||||
*/
|
||||
export interface TradeTableProps {
|
||||
theme: Theme;
|
||||
tradeData: TradeDayData[];
|
||||
minuteLoading: boolean;
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
onLoadMinuteData: () => void;
|
||||
onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void;
|
||||
selectedPeriod?: number;
|
||||
onPeriodChange?: (period: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,6 +356,7 @@ export interface AnalysisModalContentProps {
|
||||
*/
|
||||
export interface UseMarketDataReturn {
|
||||
loading: boolean;
|
||||
tradeLoading: boolean;
|
||||
summary: MarketSummary | null;
|
||||
tradeData: TradeDayData[];
|
||||
fundingData: FundingDayData[];
|
||||
@@ -380,4 +368,5 @@ export interface UseMarketDataReturn {
|
||||
analysisMap: Record<number, RiseAnalysis>;
|
||||
refetch: () => Promise<void>;
|
||||
loadMinuteData: () => Promise<void>;
|
||||
refreshTradeData: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -131,15 +131,17 @@ export const getKLineOption = (
|
||||
],
|
||||
grid: [
|
||||
{
|
||||
left: '10%',
|
||||
right: '10%',
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
height: '50%',
|
||||
containLabel: true,
|
||||
},
|
||||
{
|
||||
left: '10%',
|
||||
right: '10%',
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '65%',
|
||||
height: '20%',
|
||||
containLabel: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -312,16 +314,18 @@ export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '8%',
|
||||
right: '8%',
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '20%',
|
||||
height: '60%',
|
||||
containLabel: true,
|
||||
},
|
||||
{
|
||||
left: '8%',
|
||||
right: '8%',
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '83%',
|
||||
height: '12%',
|
||||
containLabel: true,
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
@@ -441,6 +445,437 @@ export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成日K线图配置 - 黑金主题
|
||||
*/
|
||||
export const getKLineDarkGoldOption = (
|
||||
tradeData: TradeDayData[],
|
||||
analysisMap: Record<number, RiseAnalysis>
|
||||
): EChartsOption => {
|
||||
if (!tradeData || tradeData.length === 0) return {};
|
||||
|
||||
// 黑金主题色
|
||||
const gold = '#D4AF37';
|
||||
const goldLight = '#F4D03F';
|
||||
const orange = '#FF9500';
|
||||
const red = '#FF4444';
|
||||
const green = '#00C851';
|
||||
const textColor = 'rgba(255, 255, 255, 0.85)';
|
||||
const textMuted = 'rgba(255, 255, 255, 0.5)';
|
||||
const borderColor = 'rgba(212, 175, 55, 0.2)';
|
||||
|
||||
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: 'transparent',
|
||||
animation: true,
|
||||
legend: {
|
||||
data: ['K线', 'MA5', 'MA10', 'MA20'],
|
||||
top: 5,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
lineStyle: {
|
||||
color: gold,
|
||||
width: 1,
|
||||
opacity: 0.8,
|
||||
},
|
||||
},
|
||||
backgroundColor: 'rgba(26, 26, 46, 0.95)',
|
||||
borderColor: gold,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: textMuted },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { onZero: false, lineStyle: { color: borderColor } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
scale: true,
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: borderColor,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: textMuted },
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
splitNumber: 2,
|
||||
axisLabel: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
grid: [
|
||||
{
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '8%',
|
||||
height: '55%',
|
||||
containLabel: true,
|
||||
},
|
||||
{
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '68%',
|
||||
height: '28%',
|
||||
containLabel: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
data: kData,
|
||||
itemStyle: {
|
||||
color: red,
|
||||
color0: green,
|
||||
borderColor: red,
|
||||
borderColor0: green,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MA5',
|
||||
type: 'line',
|
||||
data: ma5,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: gold,
|
||||
width: 1,
|
||||
},
|
||||
itemStyle: {
|
||||
color: gold,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MA10',
|
||||
type: 'line',
|
||||
data: ma10,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: goldLight,
|
||||
width: 1,
|
||||
},
|
||||
itemStyle: {
|
||||
color: goldLight,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MA20',
|
||||
type: 'line',
|
||||
data: ma20,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: orange,
|
||||
width: 1,
|
||||
},
|
||||
itemStyle: {
|
||||
color: orange,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '涨幅分析',
|
||||
type: 'scatter',
|
||||
data: scatterData,
|
||||
symbolSize: [80, 36],
|
||||
symbol: 'roundRect',
|
||||
itemStyle: {
|
||||
color: 'rgba(26, 26, 46, 0.9)',
|
||||
borderColor: gold,
|
||||
borderWidth: 1,
|
||||
shadowBlur: 8,
|
||||
shadowColor: 'rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '涨幅分析\n(点击查看)',
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
position: 'inside',
|
||||
color: gold,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
emphasis: {
|
||||
scale: false,
|
||||
itemStyle: {
|
||||
borderColor: goldLight,
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
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 getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): EChartsOption => {
|
||||
if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {};
|
||||
|
||||
// 黑金主题色
|
||||
const gold = '#D4AF37';
|
||||
const goldLight = '#F4D03F';
|
||||
const orange = '#FF9500';
|
||||
const red = '#FF4444';
|
||||
const green = '#00C851';
|
||||
const textColor = 'rgba(255, 255, 255, 0.85)';
|
||||
const textMuted = 'rgba(255, 255, 255, 0.5)';
|
||||
const borderColor = 'rgba(212, 175, 55, 0.2)';
|
||||
|
||||
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: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
backgroundColor: 'rgba(26, 26, 46, 0.95)',
|
||||
borderColor: gold,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[];
|
||||
let result = `<span style="color: ${gold}">${paramsArr[0].name}</span><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; color: ${goldLight}">${open.toFixed(2)}</span><br/>`;
|
||||
result += `收盘: <span style="font-weight: bold; color: ${close >= open ? red : green}">${close.toFixed(2)}</span><br/>`;
|
||||
result += `最高: <span style="font-weight: bold; color: ${goldLight}">${high.toFixed(2)}</span><br/>`;
|
||||
result += `最低: <span style="font-weight: bold; color: ${goldLight}">${low.toFixed(2)}</span><br/>`;
|
||||
result += `涨跌: <span style="font-weight: bold; color: ${close >= openPrice ? red : green}">${changePercent}%</span><br/>`;
|
||||
} else if (param.seriesName === '均价线') {
|
||||
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold; color: ${goldLight}">${(param.value as number).toFixed(2)}</span><br/>`;
|
||||
} else if (param.seriesName === '成交量') {
|
||||
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold; color: ${goldLight}">${formatNumber(param.value as number, 0)}</span><br/>`;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['分钟K线', '均价线', '成交量'],
|
||||
top: 5,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
},
|
||||
itemWidth: 25,
|
||||
itemHeight: 14,
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '10%',
|
||||
height: '65%',
|
||||
containLabel: true,
|
||||
},
|
||||
{
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '78%',
|
||||
height: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: {
|
||||
color: textMuted,
|
||||
fontSize: 10,
|
||||
interval: 'auto',
|
||||
},
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: {
|
||||
color: textMuted,
|
||||
fontSize: 10,
|
||||
},
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
scale: true,
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: textMuted, fontSize: 10 },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: borderColor,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
gridIndex: 1,
|
||||
scale: true,
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: 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: gold,
|
||||
},
|
||||
textStyle: {
|
||||
color: textMuted,
|
||||
},
|
||||
borderColor: borderColor,
|
||||
fillerColor: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '分钟K线',
|
||||
type: 'candlestick',
|
||||
data: kData,
|
||||
itemStyle: {
|
||||
color: red,
|
||||
color0: green,
|
||||
borderColor: red,
|
||||
borderColor0: green,
|
||||
borderWidth: 1,
|
||||
},
|
||||
barWidth: '60%',
|
||||
},
|
||||
{
|
||||
name: '均价线',
|
||||
type: 'line',
|
||||
data: avgPrice,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: gold,
|
||||
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)';
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成融资融券图表配置
|
||||
*/
|
||||
@@ -575,6 +1010,154 @@ export const getFundingOption = (theme: Theme, fundingData: FundingDayData[]): E
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成融资融券图表配置 - 黑金主题
|
||||
*/
|
||||
export const getFundingDarkGoldOption = (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);
|
||||
|
||||
// 黑金主题色
|
||||
const gold = '#D4AF37';
|
||||
const goldLight = '#F4D03F';
|
||||
const textColor = 'rgba(255, 255, 255, 0.85)';
|
||||
const textMuted = 'rgba(255, 255, 255, 0.5)';
|
||||
const borderColor = 'rgba(212, 175, 55, 0.2)';
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '融资融券余额走势',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: gold,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(26, 26, 46, 0.95)',
|
||||
borderColor: gold,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[];
|
||||
let result = `<span style="color: ${gold}">${paramsArr[0].name}</span><br/>`;
|
||||
paramsArr.forEach((param) => {
|
||||
result += `${param.marker} ${param.seriesName}: <span style="color: ${goldLight}; font-weight: bold">${param.value.toFixed(2)}亿</span><br/>`;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['融资余额', '融券余额'],
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: textMuted },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(亿)',
|
||||
nameTextStyle: { color: textMuted },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: borderColor,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: 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(212, 175, 55, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(212, 175, 55, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
lineStyle: {
|
||||
color: gold,
|
||||
width: 2,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(212, 175, 55, 0.5)',
|
||||
},
|
||||
itemStyle: {
|
||||
color: gold,
|
||||
borderColor: goldLight,
|
||||
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(255, 149, 0, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(255, 149, 0, 0.05)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#FF9500',
|
||||
width: 2,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(255, 149, 0, 0.5)',
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#FF9500',
|
||||
borderColor: '#FFB347',
|
||||
borderWidth: 2,
|
||||
},
|
||||
data: securities,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成股权质押图表配置
|
||||
*/
|
||||
@@ -689,10 +1272,140 @@ export const getPledgeOption = (theme: Theme, pledgeData: PledgeData[]): ECharts
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成股权质押图表配置 - 黑金主题
|
||||
*/
|
||||
export const getPledgeDarkGoldOption = (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);
|
||||
|
||||
// 黑金主题色
|
||||
const gold = '#D4AF37';
|
||||
const goldLight = '#F4D03F';
|
||||
const orange = '#FF9500';
|
||||
const textColor = 'rgba(255, 255, 255, 0.85)';
|
||||
const textMuted = 'rgba(255, 255, 255, 0.5)';
|
||||
const borderColor = 'rgba(212, 175, 55, 0.2)';
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '股权质押趋势',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: gold,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(26, 26, 46, 0.95)',
|
||||
borderColor: gold,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['质押比例', '质押笔数'],
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: textMuted },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '质押比例(%)',
|
||||
nameTextStyle: { color: textMuted },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: borderColor,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: textMuted },
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '质押笔数',
|
||||
nameTextStyle: { color: textMuted },
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
axisLabel: { color: textMuted },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '质押比例',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: gold,
|
||||
width: 2,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(212, 175, 55, 0.5)',
|
||||
},
|
||||
itemStyle: {
|
||||
color: gold,
|
||||
borderColor: goldLight,
|
||||
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: orange },
|
||||
{ offset: 1, color: 'rgba(255, 149, 0, 0.3)' },
|
||||
],
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
data: counts,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
calculateMA,
|
||||
getKLineOption,
|
||||
getKLineDarkGoldOption,
|
||||
getMinuteKLineOption,
|
||||
getMinuteKLineDarkGoldOption,
|
||||
getFundingOption,
|
||||
getFundingDarkGoldOption,
|
||||
getPledgeOption,
|
||||
getPledgeDarkGoldOption,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* CompareStockInput - 对比股票输入组件
|
||||
* 紧凑型输入框,支持模糊搜索下拉
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Button,
|
||||
Text,
|
||||
VStack,
|
||||
Spinner,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
import { BarChart2 } from 'lucide-react';
|
||||
|
||||
interface CompareStockInputProps {
|
||||
onCompare: (stockCode: string) => void;
|
||||
isLoading?: boolean;
|
||||
currentStockCode?: string;
|
||||
}
|
||||
|
||||
interface Stock {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RootState {
|
||||
stock: {
|
||||
allStocks: Stock[];
|
||||
};
|
||||
}
|
||||
|
||||
const CompareStockInput: React.FC<CompareStockInputProps> = ({
|
||||
onCompare,
|
||||
isLoading = false,
|
||||
currentStockCode,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [filteredStocks, setFilteredStocks] = useState<Stock[]>([]);
|
||||
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 从 Redux 获取全部股票列表
|
||||
const allStocks = useSelector((state: RootState) => state.stock.allStocks);
|
||||
|
||||
// 黑金主题颜色
|
||||
const borderColor = '#C9A961';
|
||||
const goldColor = '#F4D03F';
|
||||
const bgColor = '#1A202C';
|
||||
|
||||
// 模糊搜索过滤
|
||||
useEffect(() => {
|
||||
if (inputValue && inputValue.trim()) {
|
||||
const searchTerm = inputValue.trim().toLowerCase();
|
||||
const filtered = allStocks
|
||||
.filter(
|
||||
(stock) =>
|
||||
stock.code !== currentStockCode && // 排除当前股票
|
||||
(stock.code.toLowerCase().includes(searchTerm) ||
|
||||
stock.name.includes(inputValue.trim()))
|
||||
)
|
||||
.slice(0, 8); // 限制显示8条
|
||||
setFilteredStocks(filtered);
|
||||
setShowDropdown(filtered.length > 0);
|
||||
} else {
|
||||
setFilteredStocks([]);
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [inputValue, allStocks, currentStockCode]);
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 选择股票
|
||||
const handleSelectStock = (stock: Stock) => {
|
||||
setSelectedStock(stock);
|
||||
setInputValue(stock.name);
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
// 处理对比按钮点击
|
||||
const handleCompare = () => {
|
||||
if (selectedStock) {
|
||||
onCompare(selectedStock.code);
|
||||
} else if (inputValue.trim().length === 6 && /^\d{6}$/.test(inputValue.trim())) {
|
||||
// 如果直接输入了6位数字代码
|
||||
onCompare(inputValue.trim());
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
setShowDropdown(false);
|
||||
handleCompare();
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled = !selectedStock && !(inputValue.trim().length === 6 && /^\d{6}$/.test(inputValue.trim()));
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} position="relative">
|
||||
<HStack spacing={2}>
|
||||
<InputGroup size="sm" w="160px">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color={borderColor} boxSize={3} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="对比股票"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
setSelectedStock(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => inputValue && filteredStocks.length > 0 && setShowDropdown(true)}
|
||||
borderRadius="md"
|
||||
color="white"
|
||||
fontSize="sm"
|
||||
borderColor={borderColor}
|
||||
bg="transparent"
|
||||
_placeholder={{ color: borderColor, fontSize: 'sm' }}
|
||||
_focus={{
|
||||
borderColor: goldColor,
|
||||
boxShadow: `0 0 0 1px ${goldColor}`,
|
||||
}}
|
||||
_hover={{
|
||||
borderColor: goldColor,
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={isLoading ? <Spinner size="xs" /> : <BarChart2 size={14} />}
|
||||
onClick={handleCompare}
|
||||
isDisabled={isButtonDisabled || isLoading}
|
||||
bg="transparent"
|
||||
color={goldColor}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.100',
|
||||
borderColor: goldColor,
|
||||
}}
|
||||
_disabled={{
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
}}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 模糊搜索下拉列表 */}
|
||||
{showDropdown && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
mt={1}
|
||||
w="220px"
|
||||
bg={bgColor}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
maxH="240px"
|
||||
overflowY="auto"
|
||||
zIndex={1000}
|
||||
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
|
||||
>
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{filteredStocks.map((stock) => (
|
||||
<Box
|
||||
key={stock.code}
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
onClick={() => handleSelectStock(stock)}
|
||||
borderBottom="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
_last={{ borderBottom: 'none' }}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text color={goldColor} fontWeight="bold" fontSize="xs">
|
||||
{stock.code}
|
||||
</Text>
|
||||
<Text color={borderColor} fontSize="xs" noOfLines={1} maxW="120px">
|
||||
{stock.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareStockInput;
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* StockCompareModal - 股票对比弹窗组件
|
||||
* 展示对比明细、盈利能力对比、成长力对比
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Grid,
|
||||
GridItem,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
Text,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { ArrowUpIcon, ArrowDownIcon } from '@chakra-ui/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import { COMPARE_METRICS } from '../../FinancialPanorama/constants';
|
||||
import { getValueByPath, getCompareBarChartOption } from '../../FinancialPanorama/utils';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import type { StockInfo } from '../../FinancialPanorama/types';
|
||||
|
||||
interface StockCompareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentStock: string;
|
||||
currentStockInfo: StockInfo | null;
|
||||
compareStock: string;
|
||||
compareStockInfo: StockInfo | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const StockCompareModal: React.FC<StockCompareModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStock,
|
||||
currentStockInfo,
|
||||
compareStock,
|
||||
compareStockInfo,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// 黑金主题颜色
|
||||
const bgColor = '#1A202C';
|
||||
const borderColor = '#C9A961';
|
||||
const goldColor = '#F4D03F';
|
||||
const positiveColor = '#EF4444'; // 红涨
|
||||
const negativeColor = '#10B981'; // 绿跌
|
||||
|
||||
// 加载中或无数据时的显示
|
||||
if (isLoading || !currentStockInfo || !compareStockInfo) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="5xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<ModalHeader color={goldColor}>股票对比</ModalHeader>
|
||||
<ModalCloseButton color={borderColor} />
|
||||
<ModalBody pb={6}>
|
||||
<Center py={20}>
|
||||
{isLoading ? (
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color={goldColor} />
|
||||
<Text color={borderColor}>加载对比数据中...</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Text color={borderColor}>暂无对比数据</Text>
|
||||
)}
|
||||
</Center>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="5xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<ModalHeader color={goldColor}>
|
||||
{currentStockInfo?.stock_name} ({currentStock}) vs {compareStockInfo?.stock_name} ({compareStock})
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color={borderColor} />
|
||||
<ModalBody pb={6}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 对比明细表格 */}
|
||||
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<Heading size="sm" color={goldColor}>对比明细</Heading>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<TableContainer>
|
||||
<Table size="sm" variant="unstyled">
|
||||
<Thead>
|
||||
<Tr borderBottom="1px solid" borderColor={borderColor}>
|
||||
<Th color={borderColor} fontSize="xs">指标</Th>
|
||||
<Th isNumeric color={borderColor} fontSize="xs">{currentStockInfo?.stock_name}</Th>
|
||||
<Th isNumeric color={borderColor} fontSize="xs">{compareStockInfo?.stock_name}</Th>
|
||||
<Th isNumeric color={borderColor} fontSize="xs">差异</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{COMPARE_METRICS.map((metric) => {
|
||||
const value1 = getValueByPath<number>(currentStockInfo, metric.path);
|
||||
const value2 = getValueByPath<number>(compareStockInfo, metric.path);
|
||||
|
||||
let diff: number | null = null;
|
||||
let diffColor = borderColor;
|
||||
|
||||
if (value1 !== undefined && value2 !== undefined && value1 !== null && value2 !== null) {
|
||||
if (metric.format === 'percent') {
|
||||
diff = value1 - value2;
|
||||
diffColor = diff > 0 ? positiveColor : negativeColor;
|
||||
} else if (value2 !== 0) {
|
||||
diff = ((value1 - value2) / Math.abs(value2)) * 100;
|
||||
diffColor = diff > 0 ? positiveColor : negativeColor;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr key={metric.key} borderBottom="1px solid" borderColor="whiteAlpha.100">
|
||||
<Td color={borderColor} fontSize="sm">{metric.label}</Td>
|
||||
<Td isNumeric color={goldColor} fontSize="sm">
|
||||
{metric.format === 'percent'
|
||||
? formatUtils.formatPercent(value1)
|
||||
: formatUtils.formatLargeNumber(value1)}
|
||||
</Td>
|
||||
<Td isNumeric color={goldColor} fontSize="sm">
|
||||
{metric.format === 'percent'
|
||||
? formatUtils.formatPercent(value2)
|
||||
: formatUtils.formatLargeNumber(value2)}
|
||||
</Td>
|
||||
<Td isNumeric color={diffColor} fontSize="sm">
|
||||
{diff !== null ? (
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
{diff > 0 && <ArrowUpIcon boxSize={3} />}
|
||||
{diff < 0 && <ArrowDownIcon boxSize={3} />}
|
||||
<Text>
|
||||
{metric.format === 'percent'
|
||||
? `${Math.abs(diff).toFixed(2)}pp`
|
||||
: `${Math.abs(diff).toFixed(2)}%`}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 对比图表 */}
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
|
||||
<GridItem>
|
||||
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<Heading size="sm" color={goldColor}>盈利能力对比</Heading>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<ReactECharts
|
||||
option={getCompareBarChartOption(
|
||||
'盈利能力对比',
|
||||
currentStockInfo?.stock_name || '',
|
||||
compareStockInfo?.stock_name || '',
|
||||
['ROE', 'ROA', '毛利率', '净利率'],
|
||||
[
|
||||
currentStockInfo?.key_metrics?.roe,
|
||||
currentStockInfo?.key_metrics?.roa,
|
||||
currentStockInfo?.key_metrics?.gross_margin,
|
||||
currentStockInfo?.key_metrics?.net_margin,
|
||||
],
|
||||
[
|
||||
compareStockInfo?.key_metrics?.roe,
|
||||
compareStockInfo?.key_metrics?.roa,
|
||||
compareStockInfo?.key_metrics?.gross_margin,
|
||||
compareStockInfo?.key_metrics?.net_margin,
|
||||
]
|
||||
)}
|
||||
style={{ height: '280px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<Heading size="sm" color={goldColor}>成长能力对比</Heading>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<ReactECharts
|
||||
option={getCompareBarChartOption(
|
||||
'成长能力对比',
|
||||
currentStockInfo?.stock_name || '',
|
||||
compareStockInfo?.stock_name || '',
|
||||
['营收增长', '利润增长', '资产增长', '股东权益增长'],
|
||||
[
|
||||
currentStockInfo?.growth_rates?.revenue_growth,
|
||||
currentStockInfo?.growth_rates?.profit_growth,
|
||||
currentStockInfo?.growth_rates?.asset_growth,
|
||||
currentStockInfo?.growth_rates?.equity_growth,
|
||||
],
|
||||
[
|
||||
compareStockInfo?.growth_rates?.revenue_growth,
|
||||
compareStockInfo?.growth_rates?.profit_growth,
|
||||
compareStockInfo?.growth_rates?.asset_growth,
|
||||
compareStockInfo?.growth_rates?.equity_growth,
|
||||
]
|
||||
)}
|
||||
style={{ height: '280px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockCompareModal;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* StockQuoteCard 子组件导出
|
||||
*/
|
||||
|
||||
export { default as CompareStockInput } from './CompareStockInput';
|
||||
export { default as StockCompareModal } from './StockCompareModal';
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
Divider,
|
||||
Link,
|
||||
Icon,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
|
||||
import { formatRegisteredCapital, formatDate } from '../CompanyOverview/utils';
|
||||
|
||||
import FavoriteButton from '@components/FavoriteButton';
|
||||
import { CompareStockInput, StockCompareModal } from './components';
|
||||
import type { StockQuoteCardProps } from './types';
|
||||
|
||||
/**
|
||||
@@ -62,12 +64,33 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
onWatchlistToggle,
|
||||
onShare,
|
||||
basicInfo,
|
||||
// 对比相关
|
||||
currentStockInfo,
|
||||
compareStockInfo,
|
||||
isCompareLoading = false,
|
||||
onCompare,
|
||||
onCloseCompare,
|
||||
}) => {
|
||||
// 对比弹窗控制
|
||||
const { isOpen: isCompareModalOpen, onOpen: openCompareModal, onClose: closeCompareModal } = useDisclosure();
|
||||
|
||||
// 处理分享点击
|
||||
const handleShare = () => {
|
||||
onShare?.();
|
||||
};
|
||||
|
||||
// 处理对比按钮点击
|
||||
const handleCompare = (stockCode: string) => {
|
||||
onCompare?.(stockCode);
|
||||
openCompareModal();
|
||||
};
|
||||
|
||||
// 处理关闭对比弹窗
|
||||
const handleCloseCompare = () => {
|
||||
closeCompareModal();
|
||||
onCloseCompare?.();
|
||||
};
|
||||
|
||||
// 黑金主题颜色配置
|
||||
const cardBg = '#1A202C';
|
||||
const borderColor = '#C9A961';
|
||||
@@ -139,8 +162,14 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:关注 + 分享 + 时间 */}
|
||||
{/* 右侧:对比 + 关注 + 分享 + 时间 */}
|
||||
<HStack spacing={3}>
|
||||
{/* 股票对比输入 */}
|
||||
<CompareStockInput
|
||||
onCompare={handleCompare}
|
||||
isLoading={isCompareLoading}
|
||||
currentStockCode={data.code}
|
||||
/>
|
||||
<FavoriteButton
|
||||
isFavorite={isInWatchlist}
|
||||
isLoading={isWatchlistLoading}
|
||||
@@ -165,6 +194,17 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 股票对比弹窗 */}
|
||||
<StockCompareModal
|
||||
isOpen={isCompareModalOpen}
|
||||
onClose={handleCloseCompare}
|
||||
currentStock={data.code}
|
||||
currentStockInfo={currentStockInfo || null}
|
||||
compareStock={compareStockInfo?.stock_code || ''}
|
||||
compareStockInfo={compareStockInfo || null}
|
||||
isLoading={isCompareLoading}
|
||||
/>
|
||||
|
||||
{/* 1:2 布局 */}
|
||||
<Flex gap={8}>
|
||||
{/* 左栏:价格信息 (flex=1) */}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { BasicInfo } from '../CompanyOverview/types';
|
||||
import type { StockInfo } from '../FinancialPanorama/types';
|
||||
|
||||
/**
|
||||
* 股票行情卡片数据
|
||||
@@ -57,4 +58,13 @@ export interface StockQuoteCardProps {
|
||||
onShare?: () => void; // 分享回调
|
||||
// 公司基本信息
|
||||
basicInfo?: BasicInfo;
|
||||
// 股票对比相关
|
||||
currentStockInfo?: StockInfo; // 当前股票财务信息(用于对比)
|
||||
compareStockInfo?: StockInfo; // 对比股票财务信息
|
||||
isCompareLoading?: boolean; // 对比数据加载中
|
||||
onCompare?: (stockCode: string) => void; // 触发对比回调
|
||||
onCloseCompare?: () => void; // 关闭对比弹窗回调
|
||||
}
|
||||
|
||||
// 重新导出 StockInfo 类型以便外部使用
|
||||
export type { StockInfo };
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// src/views/Company/index.js
|
||||
// 公司详情页面入口 - 纯组合层
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Container, VStack } from '@chakra-ui/react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Container, VStack, useToast } from '@chakra-ui/react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { loadAllStocks } from '@store/slices/stockSlice';
|
||||
import { financialService } from '@services/financialService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// 自定义 Hooks
|
||||
import { useCompanyStock } from './hooks/useCompanyStock';
|
||||
@@ -29,6 +31,7 @@ import CompanyTabs from './components/CompanyTabs';
|
||||
*/
|
||||
const CompanyIndex = () => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
|
||||
// 1. 先获取股票代码(不带追踪回调)
|
||||
const {
|
||||
@@ -50,6 +53,57 @@ const CompanyIndex = () => {
|
||||
// 2.1 获取公司基本信息
|
||||
const { basicInfo } = useBasicInfo(stockCode);
|
||||
|
||||
// 5. 股票对比状态管理
|
||||
const [currentStockInfo, setCurrentStockInfo] = useState(null);
|
||||
const [compareStockInfo, setCompareStockInfo] = useState(null);
|
||||
const [isCompareLoading, setIsCompareLoading] = useState(false);
|
||||
|
||||
// 加载当前股票财务信息(用于对比)
|
||||
useEffect(() => {
|
||||
const loadCurrentStockInfo = async () => {
|
||||
if (!stockCode) return;
|
||||
try {
|
||||
const res = await financialService.getStockInfo(stockCode);
|
||||
setCurrentStockInfo(res.data);
|
||||
} catch (error) {
|
||||
logger.error('CompanyIndex', 'loadCurrentStockInfo', error, { stockCode });
|
||||
}
|
||||
};
|
||||
loadCurrentStockInfo();
|
||||
// 清除对比数据
|
||||
setCompareStockInfo(null);
|
||||
}, [stockCode]);
|
||||
|
||||
// 处理股票对比
|
||||
const handleCompare = useCallback(async (compareCode) => {
|
||||
if (!compareCode) return;
|
||||
|
||||
logger.debug('CompanyIndex', '开始加载对比数据', { stockCode, compareCode });
|
||||
setIsCompareLoading(true);
|
||||
|
||||
try {
|
||||
const res = await financialService.getStockInfo(compareCode);
|
||||
setCompareStockInfo(res.data);
|
||||
logger.info('CompanyIndex', '对比数据加载成功', { stockCode, compareCode });
|
||||
} catch (error) {
|
||||
logger.error('CompanyIndex', 'handleCompare', error, { stockCode, compareCode });
|
||||
toast({
|
||||
title: '加载对比数据失败',
|
||||
description: '请检查股票代码是否正确',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsCompareLoading(false);
|
||||
}
|
||||
}, [stockCode, toast]);
|
||||
|
||||
// 关闭对比弹窗
|
||||
const handleCloseCompare = useCallback(() => {
|
||||
// 可选:清除对比数据
|
||||
// setCompareStockInfo(null);
|
||||
}, []);
|
||||
|
||||
// 3. 再初始化事件追踪(传入 stockCode)
|
||||
const {
|
||||
trackStockSearched,
|
||||
@@ -92,7 +146,7 @@ const CompanyIndex = () => {
|
||||
bgColor="#1A202C"
|
||||
/>
|
||||
|
||||
{/* 股票行情卡片:价格、关键指标、主力动态、公司信息 */}
|
||||
{/* 股票行情卡片:价格、关键指标、主力动态、公司信息、股票对比 */}
|
||||
<StockQuoteCard
|
||||
data={quoteData}
|
||||
isLoading={isQuoteLoading}
|
||||
@@ -100,6 +154,12 @@ const CompanyIndex = () => {
|
||||
isWatchlistLoading={isWatchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
basicInfo={basicInfo}
|
||||
// 股票对比相关
|
||||
currentStockInfo={currentStockInfo}
|
||||
compareStockInfo={compareStockInfo}
|
||||
isCompareLoading={isCompareLoading}
|
||||
onCompare={handleCompare}
|
||||
onCloseCompare={handleCloseCompare}
|
||||
/>
|
||||
|
||||
{/* Tab 切换区域:概览、行情、财务、预测 */}
|
||||
|
||||
Reference in New Issue
Block a user