mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-04 11:30:58 +08:00
feat: migrate to CoinAnk API and improve chart UI
- Chart improvements: professional styling, popular symbols quick selection, simplified B/S legend - Data source migration: use CoinAnk API exclusively for all kline data - Code cleanup: remove Binance WebSocket cache and related code (websocket_client.go, combined_streams.go, monitor.go) - Log optimization: reduce hook spam, suppress 404 errors, increase P&L diff threshold - Lighter integration: add order sync functionality, fix market order precision - Remove ticker merge logic for simplicity
This commit is contained in:
735
web/src/components/AdvancedChart.tsx
Normal file
735
web/src/components/AdvancedChart.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createChart,
|
||||
IChartApi,
|
||||
ISeriesApi,
|
||||
Time,
|
||||
UTCTimestamp,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
HistogramSeries,
|
||||
createSeriesMarkers,
|
||||
} from 'lightweight-charts'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { httpClient } from '../lib/httpClient'
|
||||
import {
|
||||
calculateSMA,
|
||||
calculateEMA,
|
||||
calculateBollingerBands,
|
||||
type Kline,
|
||||
} from '../utils/indicators'
|
||||
import { Settings, TrendingUp, BarChart2 } from 'lucide-react'
|
||||
|
||||
// 订单接口定义
|
||||
interface OrderMarker {
|
||||
time: number
|
||||
price: number
|
||||
side: 'long' | 'short'
|
||||
rawSide: string // 原始 side 字段 (buy/sell from database)
|
||||
action: 'open' | 'close'
|
||||
pnl?: number
|
||||
symbol: string
|
||||
}
|
||||
|
||||
interface AdvancedChartProps {
|
||||
symbol: string
|
||||
interval?: string
|
||||
traderID?: string
|
||||
height?: number
|
||||
exchange?: string // 交易所类型:binance, bybit, okx, bitget, hyperliquid, aster, lighter
|
||||
onSymbolChange?: (symbol: string) => void // 币种切换回调
|
||||
}
|
||||
|
||||
// 指标配置
|
||||
interface IndicatorConfig {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
color: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
// 热门币种
|
||||
const POPULAR_SYMBOLS = [
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
'SOLUSDT',
|
||||
'BNBUSDT',
|
||||
'XRPUSDT',
|
||||
'DOGEUSDT',
|
||||
'ADAUSDT',
|
||||
'AVAXUSDT',
|
||||
]
|
||||
|
||||
export function AdvancedChart({
|
||||
symbol = 'BTCUSDT',
|
||||
interval = '5m',
|
||||
traderID,
|
||||
height = 550,
|
||||
exchange = 'binance', // 默认使用 binance
|
||||
onSymbolChange,
|
||||
}: AdvancedChartProps) {
|
||||
const { language } = useLanguage()
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<IChartApi | null>(null)
|
||||
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||
const volumeSeriesRef = useRef<ISeriesApi<'Histogram'> | null>(null)
|
||||
const indicatorSeriesRef = useRef<Map<string, ISeriesApi<any>>>(new Map())
|
||||
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showIndicatorPanel, setShowIndicatorPanel] = useState(false)
|
||||
|
||||
// 指标配置
|
||||
const [indicators, setIndicators] = useState<IndicatorConfig[]>([
|
||||
{ id: 'volume', name: 'Volume', enabled: true, color: '#3B82F6' },
|
||||
{ id: 'ma5', name: 'MA5', enabled: false, color: '#FF6B6B', params: { period: 5 } },
|
||||
{ id: 'ma10', name: 'MA10', enabled: false, color: '#4ECDC4', params: { period: 10 } },
|
||||
{ id: 'ma20', name: 'MA20', enabled: false, color: '#FFD93D', params: { period: 20 } },
|
||||
{ id: 'ma60', name: 'MA60', enabled: false, color: '#95E1D3', params: { period: 60 } },
|
||||
{ id: 'ema12', name: 'EMA12', enabled: false, color: '#A8E6CF', params: { period: 12 } },
|
||||
{ id: 'ema26', name: 'EMA26', enabled: false, color: '#FFD3B6', params: { period: 26 } },
|
||||
{ id: 'bb', name: 'Bollinger Bands', enabled: false, color: '#9B59B6' },
|
||||
])
|
||||
|
||||
// 从服务获取K线数据
|
||||
const fetchKlineData = async (symbol: string, interval: string) => {
|
||||
try {
|
||||
const limit = 1500
|
||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
||||
const result = await httpClient.get(klineUrl)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error('Failed to fetch kline data')
|
||||
}
|
||||
|
||||
return result.data.map((candle: any) => ({
|
||||
time: Math.floor(candle.openTime / 1000) as UTCTimestamp,
|
||||
open: candle.open,
|
||||
high: candle.high,
|
||||
low: candle.low,
|
||||
close: candle.close,
|
||||
volume: candle.volume,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('[AdvancedChart] Error fetching kline:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 解析时间:支持 Unix 时间戳(数字)或字符串格式
|
||||
const parseCustomTime = (time: any): number => {
|
||||
if (!time) {
|
||||
console.warn('[AdvancedChart] Empty time value')
|
||||
return 0
|
||||
}
|
||||
|
||||
// 如果已经是数字(Unix 时间戳),直接返回
|
||||
if (typeof time === 'number') {
|
||||
console.log('[AdvancedChart] ✅ Unix timestamp:', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||
return time
|
||||
}
|
||||
|
||||
const timeStr = String(time)
|
||||
console.log('[AdvancedChart] Parsing time string:', timeStr)
|
||||
|
||||
// 尝试标准ISO格式
|
||||
const isoTime = new Date(timeStr).getTime()
|
||||
if (!isNaN(isoTime) && isoTime > 0) {
|
||||
const timestamp = Math.floor(isoTime / 1000)
|
||||
console.log('[AdvancedChart] ✅ Parsed as ISO:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// 解析自定义格式 "MM-DD HH:mm UTC" (兼容旧数据)
|
||||
const match = timeStr.match(/(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/)
|
||||
if (match) {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [_, month, day, hour, minute] = match
|
||||
const date = new Date(Date.UTC(
|
||||
currentYear,
|
||||
parseInt(month) - 1,
|
||||
parseInt(day),
|
||||
parseInt(hour),
|
||||
parseInt(minute)
|
||||
))
|
||||
const timestamp = Math.floor(date.getTime() / 1000)
|
||||
console.log('[AdvancedChart] ✅ Parsed as custom format:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||
return timestamp
|
||||
}
|
||||
|
||||
console.error('[AdvancedChart] ❌ Failed to parse time:', timeStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 获取订单数据
|
||||
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
|
||||
try {
|
||||
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
||||
// 获取已成交的订单,限制50条避免标记太多重叠
|
||||
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
|
||||
|
||||
console.log('[AdvancedChart] Orders API response:', result)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.warn('[AdvancedChart] No orders found, result:', result)
|
||||
return []
|
||||
}
|
||||
|
||||
const orders = result.data
|
||||
console.log('[AdvancedChart] Raw orders data:', orders)
|
||||
const markers: OrderMarker[] = []
|
||||
|
||||
orders.forEach((order: any) => {
|
||||
console.log('[AdvancedChart] Processing order:', order)
|
||||
|
||||
// 处理字段名:支持PascalCase和snake_case
|
||||
const filledAt = order.filled_at || order.FilledAt || order.created_at || order.CreatedAt
|
||||
const avgPrice = order.avg_fill_price || order.AvgFillPrice || order.price || order.Price
|
||||
const orderAction = order.order_action || order.OrderAction
|
||||
const side = (order.side || order.Side)?.toLowerCase() // BUY/SELL
|
||||
const symbol = order.symbol || order.Symbol
|
||||
|
||||
// 跳过没有成交时间或价格的订单
|
||||
if (!filledAt || !avgPrice || avgPrice === 0) {
|
||||
console.warn('[AdvancedChart] Skipping order - missing data:', { filledAt, avgPrice })
|
||||
return
|
||||
}
|
||||
|
||||
const timeSeconds = parseCustomTime(filledAt)
|
||||
if (timeSeconds === 0) {
|
||||
console.warn('[AdvancedChart] Skipping order - invalid time:', filledAt)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 order_action 判断是开仓还是平仓
|
||||
let action: 'open' | 'close' = 'open'
|
||||
let positionSide: 'long' | 'short' = 'long'
|
||||
|
||||
if (orderAction) {
|
||||
if (orderAction.includes('OPEN')) {
|
||||
action = 'open'
|
||||
positionSide = orderAction.includes('LONG') ? 'long' : 'short'
|
||||
} else if (orderAction.includes('CLOSE')) {
|
||||
action = 'close'
|
||||
positionSide = orderAction.includes('LONG') ? 'long' : 'short'
|
||||
}
|
||||
} else {
|
||||
// 如果没有 order_action,根据 side 判断
|
||||
positionSide = side === 'buy' ? 'long' : 'short'
|
||||
}
|
||||
|
||||
console.log('[AdvancedChart] Order marker:', {
|
||||
time: timeSeconds,
|
||||
price: avgPrice,
|
||||
side: positionSide,
|
||||
rawSide: side,
|
||||
action,
|
||||
orderAction
|
||||
})
|
||||
|
||||
markers.push({
|
||||
time: timeSeconds,
|
||||
price: avgPrice,
|
||||
side: positionSide,
|
||||
rawSide: side, // 原始 side 字段 (buy/sell)
|
||||
action: action,
|
||||
symbol,
|
||||
})
|
||||
})
|
||||
|
||||
console.log('[AdvancedChart] Final markers:', markers)
|
||||
return markers
|
||||
} catch (err) {
|
||||
console.error('[AdvancedChart] Error fetching orders:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) return
|
||||
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: height,
|
||||
layout: {
|
||||
background: { color: '#0B0E11' },
|
||||
textColor: '#B7BDC6',
|
||||
fontSize: 12,
|
||||
},
|
||||
grid: {
|
||||
vertLines: {
|
||||
color: 'rgba(43, 49, 57, 0.2)',
|
||||
style: 1,
|
||||
visible: true,
|
||||
},
|
||||
horzLines: {
|
||||
color: 'rgba(43, 49, 57, 0.2)',
|
||||
style: 1,
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
crosshair: {
|
||||
mode: 1,
|
||||
vertLine: {
|
||||
color: 'rgba(240, 185, 11, 0.5)',
|
||||
width: 1,
|
||||
style: 2,
|
||||
labelBackgroundColor: '#F0B90B',
|
||||
},
|
||||
horzLine: {
|
||||
color: 'rgba(240, 185, 11, 0.5)',
|
||||
width: 1,
|
||||
style: 2,
|
||||
labelBackgroundColor: '#F0B90B',
|
||||
},
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#2B3139',
|
||||
scaleMargins: {
|
||||
top: 0.1,
|
||||
bottom: 0.25,
|
||||
},
|
||||
borderVisible: true,
|
||||
entireTextOnly: false,
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#2B3139',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
borderVisible: true,
|
||||
rightOffset: 5,
|
||||
barSpacing: 8,
|
||||
},
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
pressedMouseMove: true,
|
||||
horzTouchDrag: true,
|
||||
vertTouchDrag: true,
|
||||
},
|
||||
handleScale: {
|
||||
axisPressedMouseMove: true,
|
||||
mouseWheel: true,
|
||||
pinch: true,
|
||||
},
|
||||
})
|
||||
|
||||
chartRef.current = chart
|
||||
|
||||
// 创建K线系列
|
||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#0ECB81',
|
||||
downColor: '#F6465D',
|
||||
borderUpColor: '#0ECB81',
|
||||
borderDownColor: '#F6465D',
|
||||
wickUpColor: '#0ECB81',
|
||||
wickDownColor: '#F6465D',
|
||||
})
|
||||
candlestickSeriesRef.current = candlestickSeries as any
|
||||
|
||||
// 创建成交量系列
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
color: '#26a69a',
|
||||
priceFormat: {
|
||||
type: 'volume',
|
||||
},
|
||||
priceScaleId: '',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
})
|
||||
volumeSeriesRef.current = volumeSeries as any
|
||||
|
||||
// 响应式调整
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current && chartRef.current) {
|
||||
chartRef.current.applyOptions({
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart.remove()
|
||||
}
|
||||
}, [height])
|
||||
|
||||
// 加载数据和指标
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!candlestickSeriesRef.current) return
|
||||
|
||||
console.log('[AdvancedChart] Loading data for', symbol, interval)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 1. 获取K线数据
|
||||
const klineData = await fetchKlineData(symbol, interval)
|
||||
console.log('[AdvancedChart] Loaded', klineData.length, 'klines')
|
||||
candlestickSeriesRef.current.setData(klineData)
|
||||
|
||||
// 2. 显示成交量
|
||||
if (volumeSeriesRef.current) {
|
||||
const volumeEnabled = indicators.find(i => i.id === 'volume')?.enabled
|
||||
if (volumeEnabled) {
|
||||
const volumeData = klineData.map((k: Kline) => ({
|
||||
time: k.time,
|
||||
value: k.volume || 0,
|
||||
color: k.close >= k.open ? 'rgba(14, 203, 129, 0.5)' : 'rgba(246, 70, 93, 0.5)',
|
||||
}))
|
||||
volumeSeriesRef.current.setData(volumeData)
|
||||
} else {
|
||||
// 关闭成交量时清空数据
|
||||
volumeSeriesRef.current.setData([])
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 添加指标
|
||||
updateIndicators(klineData)
|
||||
|
||||
// 4. 获取并显示订单标记
|
||||
if (traderID && candlestickSeriesRef.current) {
|
||||
console.log('[AdvancedChart] Starting to fetch orders...')
|
||||
const orders = await fetchOrders(traderID, symbol)
|
||||
console.log('[AdvancedChart] Received orders:', orders)
|
||||
|
||||
if (orders.length > 0) {
|
||||
console.log('[AdvancedChart] Creating markers from', orders.length, 'orders')
|
||||
|
||||
// 过滤掉无效时间戳的订单(小于2024年的时间戳)
|
||||
const minValidTimestamp = new Date('2024-01-01').getTime() / 1000
|
||||
const validOrders = orders.filter(order => {
|
||||
if (order.time < minValidTimestamp) {
|
||||
console.warn('[AdvancedChart] ⚠️ Skipping order with invalid timestamp:', order.time, '(', new Date(order.time * 1000).toISOString(), ')')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
console.log('[AdvancedChart] Valid orders:', validOrders.length, 'out of', orders.length)
|
||||
|
||||
const markers = validOrders.map(order => {
|
||||
// 直接使用 rawSide 字段判断买卖(更准确)
|
||||
// rawSide = 'buy' → 绿色 B
|
||||
// rawSide = 'sell' → 红色 S
|
||||
const isBuy = order.rawSide === 'buy'
|
||||
|
||||
const marker = {
|
||||
time: order.time as Time,
|
||||
position: 'belowBar' as const,
|
||||
color: isBuy ? '#0ECB81' : '#F6465D', // BUY绿色, SELL红色
|
||||
shape: 'circle' as const, // 使用圆形作为背景
|
||||
text: isBuy ? 'B' : 'S', // 显示 B 或 S
|
||||
size: 1, // 稍微大一点以显示文字
|
||||
}
|
||||
|
||||
console.log('[AdvancedChart] ✅ Created marker:', marker.text, 'for', order.rawSide, 'at', new Date(order.time * 1000).toISOString())
|
||||
return marker
|
||||
})
|
||||
|
||||
console.log('[AdvancedChart] Setting', markers.length, 'markers on candlestick series')
|
||||
console.log('[AdvancedChart] Markers data:', JSON.stringify(markers, null, 2))
|
||||
|
||||
try {
|
||||
// 使用 v5 API: createSeriesMarkers
|
||||
if (seriesMarkersRef.current) {
|
||||
// 如果已经存在,更新标记
|
||||
seriesMarkersRef.current.setMarkers(markers)
|
||||
} else {
|
||||
// 首次创建标记
|
||||
seriesMarkersRef.current = createSeriesMarkers(candlestickSeriesRef.current, markers)
|
||||
}
|
||||
console.log('[AdvancedChart] ✅ Markers set successfully!')
|
||||
} catch (err) {
|
||||
console.error('[AdvancedChart] ❌ Failed to set markers:', err)
|
||||
}
|
||||
} else {
|
||||
console.log('[AdvancedChart] No orders found, clearing markers')
|
||||
try {
|
||||
if (seriesMarkersRef.current) {
|
||||
seriesMarkersRef.current.setMarkers([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AdvancedChart] Failed to clear markers:', err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[AdvancedChart] Skipping markers:', {
|
||||
hasTraderID: !!traderID,
|
||||
hasSeries: !!candlestickSeriesRef.current
|
||||
})
|
||||
}
|
||||
|
||||
// 自动适配视图
|
||||
chartRef.current?.timeScale().fitContent()
|
||||
setLoading(false)
|
||||
} catch (err: any) {
|
||||
console.error('[AdvancedChart] Error loading data:', err)
|
||||
setError(err.message || 'Failed to load chart data')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
|
||||
// 实时自动刷新 (5秒更新一次)
|
||||
const refreshInterval = setInterval(loadData, 5000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
}, [symbol, interval, traderID, indicators])
|
||||
|
||||
// 更新指标
|
||||
const updateIndicators = (klineData: Kline[]) => {
|
||||
if (!chartRef.current) return
|
||||
|
||||
// 清除旧指标
|
||||
indicatorSeriesRef.current.forEach(series => {
|
||||
chartRef.current?.removeSeries(series as any)
|
||||
})
|
||||
indicatorSeriesRef.current.clear()
|
||||
|
||||
// 添加启用的指标
|
||||
indicators.forEach(indicator => {
|
||||
if (!indicator.enabled || !chartRef.current) return
|
||||
|
||||
if (indicator.id.startsWith('ma')) {
|
||||
const maData = calculateSMA(klineData, indicator.params.period)
|
||||
const series = chartRef.current.addSeries(LineSeries, {
|
||||
color: indicator.color,
|
||||
lineWidth: 2,
|
||||
title: indicator.name,
|
||||
})
|
||||
series.setData(maData as any)
|
||||
indicatorSeriesRef.current.set(indicator.id, series)
|
||||
} else if (indicator.id.startsWith('ema')) {
|
||||
const emaData = calculateEMA(klineData, indicator.params.period)
|
||||
const series = chartRef.current.addSeries(LineSeries, {
|
||||
color: indicator.color,
|
||||
lineWidth: 2,
|
||||
title: indicator.name,
|
||||
lineStyle: 2, // 虚线
|
||||
})
|
||||
series.setData(emaData as any)
|
||||
indicatorSeriesRef.current.set(indicator.id, series)
|
||||
} else if (indicator.id === 'bb') {
|
||||
const bbData = calculateBollingerBands(klineData)
|
||||
|
||||
const upperSeries = chartRef.current.addSeries(LineSeries, {
|
||||
color: indicator.color,
|
||||
lineWidth: 1,
|
||||
title: 'BB Upper',
|
||||
})
|
||||
upperSeries.setData(bbData.map(d => ({ time: d.time as any, value: d.upper })))
|
||||
|
||||
const middleSeries = chartRef.current.addSeries(LineSeries, {
|
||||
color: indicator.color,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
title: 'BB Middle',
|
||||
})
|
||||
middleSeries.setData(bbData.map(d => ({ time: d.time as any, value: d.middle })))
|
||||
|
||||
const lowerSeries = chartRef.current.addSeries(LineSeries, {
|
||||
color: indicator.color,
|
||||
lineWidth: 1,
|
||||
title: 'BB Lower',
|
||||
})
|
||||
lowerSeries.setData(bbData.map(d => ({ time: d.time as any, value: d.lower })))
|
||||
|
||||
indicatorSeriesRef.current.set(indicator.id + '_upper', upperSeries)
|
||||
indicatorSeriesRef.current.set(indicator.id + '_middle', middleSeries)
|
||||
indicatorSeriesRef.current.set(indicator.id + '_lower', lowerSeries)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换指标
|
||||
const toggleIndicator = (id: string) => {
|
||||
setIndicators(prev =>
|
||||
prev.map(ind => (ind.id === id ? { ...ind, enabled: !ind.enabled } : ind))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative shadow-xl"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #0F1215 0%, #0B0E11 100%)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(43, 49, 57, 0.5)',
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 - 专业化设计 */}
|
||||
<div
|
||||
className="px-4 py-2.5 space-y-2"
|
||||
style={{ borderBottom: '1px solid #2B3139', background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)' }}
|
||||
>
|
||||
{/* 第一行:标题和控制按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-yellow-400" />
|
||||
<h3 className="text-base font-bold" style={{ color: '#F0B90B' }}>
|
||||
{symbol}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
{interval}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div className="text-xs px-2 py-1 rounded" style={{ background: '#2B3139', color: '#F0B90B' }}>
|
||||
{language === 'zh' ? '更新中...' : 'Updating...'}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowIndicatorPanel(!showIndicatorPanel)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||
style={{
|
||||
background: showIndicatorPanel ? 'rgba(240, 185, 11, 0.15)' : 'rgba(255, 255, 255, 0.05)',
|
||||
color: showIndicatorPanel ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${showIndicatorPanel ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`,
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
<span>{language === 'zh' ? '指标' : 'Indicators'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:热门币种快速选择 */}
|
||||
{onSymbolChange && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium mr-1" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '快速选择:' : 'Quick:'}
|
||||
</span>
|
||||
{POPULAR_SYMBOLS.map((sym) => (
|
||||
<button
|
||||
key={sym}
|
||||
onClick={() => onSymbolChange(sym)}
|
||||
className="px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: symbol === sym ? 'rgba(240, 185, 11, 0.2)' : 'rgba(43, 49, 57, 0.5)',
|
||||
color: symbol === sym ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${symbol === sym ? 'rgba(240, 185, 11, 0.4)' : 'transparent'}`,
|
||||
}}
|
||||
>
|
||||
{sym.replace('USDT', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 指标面板 - 专业化设计 */}
|
||||
{showIndicatorPanel && (
|
||||
<div
|
||||
className="absolute top-16 right-4 z-10 rounded-lg shadow-2xl backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1A1E23 0%, #0F1215 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
maxHeight: '500px',
|
||||
minWidth: '280px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'rgba(43, 49, 57, 0.5)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="w-4 h-4 text-yellow-400" />
|
||||
<h4 className="text-sm font-bold text-white">
|
||||
{language === 'zh' ? '技术指标' : 'Technical Indicators'}
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowIndicatorPanel(false)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-lg">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 指标列表 */}
|
||||
<div className="p-3 space-y-1">
|
||||
{indicators.map(indicator => (
|
||||
<label
|
||||
key={indicator.id}
|
||||
className="flex items-center gap-3 p-2.5 rounded-md hover:bg-white/5 cursor-pointer transition-all group"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={indicator.enabled}
|
||||
onChange={() => toggleIndicator(indicator.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 text-yellow-500 focus:ring-2 focus:ring-yellow-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="w-8 h-3 rounded-sm border border-white/10"
|
||||
style={{ backgroundColor: indicator.color }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300 group-hover:text-white transition-colors flex-1">
|
||||
{indicator.name}
|
||||
</span>
|
||||
{indicator.enabled && (
|
||||
<span className="text-xs text-yellow-400">●</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div
|
||||
className="px-4 py-2 text-xs text-gray-500 border-t"
|
||||
style={{ borderColor: 'rgba(43, 49, 57, 0.5)' }}
|
||||
>
|
||||
{language === 'zh' ? '点击选择需要显示的指标' : 'Click to toggle indicators'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图表容器 */}
|
||||
<div ref={chartContainerRef} style={{ position: 'relative' }} />
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'rgba(11, 14, 17, 0.9)' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图例说明 - 简化版 */}
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-2.5 text-xs"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#0F1215' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: '#0ECB81', color: '#0B0E11' }}>
|
||||
B
|
||||
</div>
|
||||
<span style={{ color: '#EAECEF' }}>{language === 'zh' ? '买入 (BUY)' : 'BUY'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: '#F6465D', color: '#0B0E11' }}>
|
||||
S
|
||||
</div>
|
||||
<span style={{ color: '#EAECEF' }}>{language === 'zh' ? '卖出 (SELL)' : 'SELL'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { EquityChart } from './EquityChart'
|
||||
import { TradingViewChart } from './TradingViewChart'
|
||||
import { AdvancedChart } from './AdvancedChart'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { BarChart3, CandlestickChart } from 'lucide-react'
|
||||
@@ -14,11 +14,24 @@ interface ChartTabsProps {
|
||||
}
|
||||
|
||||
type ChartTab = 'equity' | 'kline'
|
||||
type Interval = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d'
|
||||
|
||||
const INTERVALS: { value: Interval; label: string }[] = [
|
||||
{ value: '1m', label: '1m' },
|
||||
{ value: '5m', label: '5m' },
|
||||
{ value: '15m', label: '15m' },
|
||||
{ value: '30m', label: '30m' },
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '4h', label: '4h' },
|
||||
{ value: '1d', label: '1d' },
|
||||
]
|
||||
|
||||
export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
|
||||
const { language } = useLanguage()
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('equity')
|
||||
const [chartSymbol, setChartSymbol] = useState<string>('BTCUSDT')
|
||||
const [interval, setInterval] = useState<Interval>('5m')
|
||||
const [symbolInput, setSymbolInput] = useState('')
|
||||
|
||||
// 当从外部选择币种时,自动切换到K线图
|
||||
useEffect(() => {
|
||||
@@ -29,45 +42,105 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
}
|
||||
}, [selectedSymbol, updateKey])
|
||||
|
||||
// 处理手动输入币种
|
||||
const handleSymbolSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (symbolInput.trim()) {
|
||||
const symbol = symbolInput.trim().toUpperCase()
|
||||
setChartSymbol(symbol)
|
||||
setSymbolInput('')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
||||
|
||||
return (
|
||||
<div className="binance-card">
|
||||
{/* Tab Headers - 这是Tab切换按钮区域 */}
|
||||
{/* Tab Headers - 专业化工具栏 */}
|
||||
<div
|
||||
className="flex items-center gap-2 p-3"
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{
|
||||
borderBottom: '1px solid #2B3139',
|
||||
background: '#0B0E11',
|
||||
background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to equity')
|
||||
setActiveTab('equity')
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'equity'
|
||||
? 'bg-yellow-500/10 text-yellow-500 border border-yellow-500/30 shadow-[0_0_10px_rgba(252,213,53,0.15)]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
{t('accountEquityCurve', language)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to equity')
|
||||
setActiveTab('equity')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'equity'
|
||||
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<span>{t('accountEquityCurve', language)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to kline')
|
||||
setActiveTab('kline')
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'kline'
|
||||
? 'bg-yellow-500/10 text-yellow-500 border border-yellow-500/30 shadow-[0_0_10px_rgba(252,213,53,0.15)]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<CandlestickChart className="w-4 h-4" />
|
||||
{t('marketChart', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to kline')
|
||||
setActiveTab('kline')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'kline'
|
||||
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<CandlestickChart className="w-3.5 h-3.5" />
|
||||
<span>{t('marketChart', language)}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 币种选择器和时间周期选择器 - 仅在K线图模式下显示 */}
|
||||
{activeTab === 'kline' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 当前币种显示 */}
|
||||
<div className="px-2.5 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-xs font-bold text-yellow-400">
|
||||
{chartSymbol}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||
|
||||
{/* 时间周期选择器 - 更紧凑专业 */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{INTERVALS.map((int) => (
|
||||
<button
|
||||
key={int.value}
|
||||
onClick={() => setInterval(int.value)}
|
||||
className={`px-2 py-1 text-[10px] font-medium transition-all ${
|
||||
interval === int.value
|
||||
? 'bg-yellow-500/20 text-yellow-400 rounded'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{int.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||
|
||||
{/* 币种输入框 - 更紧凑 */}
|
||||
<form onSubmit={handleSymbolSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={symbolInput}
|
||||
onChange={(e) => setSymbolInput(e.target.value)}
|
||||
placeholder="输入币种..."
|
||||
className="px-2 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-[11px] text-white placeholder-gray-600 focus:outline-none focus:border-yellow-500/50 w-24"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2 py-1 bg-yellow-500/15 text-yellow-400 border border-yellow-500/30 rounded text-[10px] font-medium hover:bg-yellow-500/25 transition-all"
|
||||
>
|
||||
GO
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
@@ -86,19 +159,19 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`kline-${chartSymbol}-${exchangeId}`}
|
||||
key={`kline-${chartSymbol}-${interval}-${exchangeId}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<TradingViewChart
|
||||
height={400}
|
||||
embedded
|
||||
defaultSymbol={chartSymbol}
|
||||
defaultExchange={exchangeId}
|
||||
key={`${chartSymbol}-${exchangeId}-${updateKey || ''}`}
|
||||
<AdvancedChart
|
||||
symbol={chartSymbol}
|
||||
interval={interval}
|
||||
traderID={traderId}
|
||||
height={550}
|
||||
onSymbolChange={setChartSymbol}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
401
web/src/components/ChartWithOrders.tsx
Normal file
401
web/src/components/ChartWithOrders.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createChart,
|
||||
IChartApi,
|
||||
ISeriesApi,
|
||||
Time,
|
||||
UTCTimestamp,
|
||||
CandlestickSeries,
|
||||
createSeriesMarkers,
|
||||
} from 'lightweight-charts'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { httpClient } from '../lib/httpClient'
|
||||
|
||||
// 订单接口定义
|
||||
interface OrderMarker {
|
||||
time: number // Unix timestamp (seconds)
|
||||
price: number
|
||||
side: string // BUY, SELL
|
||||
orderAction: string // OPEN_LONG, CLOSE_LONG, STOP_LOSS, TAKE_PROFIT, etc.
|
||||
status: string // NEW, FILLED, CANCELED, etc.
|
||||
symbol: string
|
||||
}
|
||||
|
||||
// K线数据接口
|
||||
interface KlineData {
|
||||
time: UTCTimestamp
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume?: number
|
||||
}
|
||||
|
||||
interface ChartWithOrdersProps {
|
||||
symbol: string
|
||||
interval?: string // 1m, 5m, 15m, 1h, 4h, 1d
|
||||
traderID?: string // 用于获取该trader的订单
|
||||
height?: number
|
||||
exchange?: string // 交易所类型:binance, bybit, okx, bitget, hyperliquid, aster, lighter
|
||||
}
|
||||
|
||||
export function ChartWithOrders({
|
||||
symbol = 'BTCUSDT',
|
||||
interval = '5m',
|
||||
traderID,
|
||||
height = 500,
|
||||
exchange = 'binance', // 默认使用 binance
|
||||
}: ChartWithOrdersProps) {
|
||||
const { language } = useLanguage()
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<IChartApi | null>(null)
|
||||
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 解析时间:支持 Unix 时间戳(数字)或字符串格式
|
||||
const parseCustomTime = (time: any): number => {
|
||||
if (!time) {
|
||||
console.warn('[ChartWithOrders] Empty time value')
|
||||
return 0
|
||||
}
|
||||
|
||||
// 如果已经是数字(Unix 时间戳),直接返回
|
||||
if (typeof time === 'number') {
|
||||
console.log('[ChartWithOrders] ✅ Unix timestamp:', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||
return time
|
||||
}
|
||||
|
||||
const timeStr = String(time)
|
||||
console.log('[ChartWithOrders] Parsing time string:', timeStr)
|
||||
|
||||
// 尝试标准ISO格式
|
||||
const isoTime = new Date(timeStr).getTime()
|
||||
if (!isNaN(isoTime) && isoTime > 0) {
|
||||
const timestamp = Math.floor(isoTime / 1000)
|
||||
console.log('[ChartWithOrders] ✅ Parsed as ISO:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// 解析自定义格式 "MM-DD HH:mm UTC" (兼容旧数据)
|
||||
const match = timeStr.match(/(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/)
|
||||
if (match) {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [_, month, day, hour, minute] = match
|
||||
const date = new Date(Date.UTC(
|
||||
currentYear,
|
||||
parseInt(month) - 1,
|
||||
parseInt(day),
|
||||
parseInt(hour),
|
||||
parseInt(minute)
|
||||
))
|
||||
const timestamp = Math.floor(date.getTime() / 1000)
|
||||
console.log('[ChartWithOrders] ✅ Parsed as custom format:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||
return timestamp
|
||||
}
|
||||
|
||||
console.error('[ChartWithOrders] ❌ Failed to parse time:', timeStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 从我们的服务获取K线数据
|
||||
const fetchKlineData = async (symbol: string, interval: string): Promise<KlineData[]> => {
|
||||
try {
|
||||
const limit = 2000 // 获取最近2000根K线 (更多历史数据)
|
||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
||||
|
||||
const result = await httpClient.get(klineUrl)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error('Failed to fetch kline data from our service')
|
||||
}
|
||||
|
||||
const data = result.data
|
||||
|
||||
// 转换后端数据格式到 lightweight-charts 格式
|
||||
// 后端返回的是 market.Kline 格式: {OpenTime, Open, High, Low, Close, Volume, ...}
|
||||
return data.map((candle: any) => ({
|
||||
time: Math.floor(candle.openTime / 1000) as UTCTimestamp, // 毫秒转秒
|
||||
open: candle.open,
|
||||
high: candle.high,
|
||||
low: candle.low,
|
||||
close: candle.close,
|
||||
volume: candle.volume,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Error fetching kline data:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 获取订单数据
|
||||
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
|
||||
try {
|
||||
// 从后端 API 获取该 trader 的订单记录(只获取已成交的订单)
|
||||
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.warn('Failed to fetch orders:', result.message)
|
||||
return []
|
||||
}
|
||||
|
||||
const orders = result.data
|
||||
const markers: OrderMarker[] = []
|
||||
|
||||
// 转换订单数据为标记格式
|
||||
orders.forEach((order: any) => {
|
||||
const createdAt = order.created_at || order.CreatedAt
|
||||
const filledAt = order.filled_at || order.FilledAt
|
||||
const avgPrice = order.avg_fill_price || order.AvgFillPrice
|
||||
const price = order.price || order.Price
|
||||
const orderAction = order.order_action || order.OrderAction
|
||||
const side = order.side || order.Side
|
||||
const status = order.status || order.Status
|
||||
const symbol = order.symbol || order.Symbol
|
||||
|
||||
// 使用成交时间(如果有)或创建时间
|
||||
const orderTime = filledAt || createdAt
|
||||
if (!orderTime) return
|
||||
|
||||
const timeSeconds = parseCustomTime(orderTime)
|
||||
if (timeSeconds === 0) return
|
||||
|
||||
// 使用平均成交价(如果有)或订单价格
|
||||
const orderPrice = avgPrice || price
|
||||
if (!orderPrice || orderPrice === 0) return
|
||||
|
||||
markers.push({
|
||||
time: timeSeconds,
|
||||
price: orderPrice,
|
||||
side: side || 'BUY',
|
||||
orderAction: orderAction || 'UNKNOWN',
|
||||
status: status || 'FILLED',
|
||||
symbol: symbol || '',
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`[ChartWithOrders] Loaded ${markers.length} order markers for ${symbol}`)
|
||||
return markers
|
||||
} catch (err) {
|
||||
console.error('Error fetching orders:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) {
|
||||
console.error('[ChartWithOrders] Container ref is null')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[ChartWithOrders] Initializing chart for', symbol, interval)
|
||||
|
||||
try {
|
||||
// 创建图表
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: height,
|
||||
layout: {
|
||||
background: { color: '#0B0E11' },
|
||||
textColor: '#EAECEF',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
|
||||
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: 1, // Normal crosshair
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#2B3139',
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#2B3139',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
},
|
||||
})
|
||||
|
||||
chartRef.current = chart
|
||||
|
||||
// 创建K线系列 (使用 v5 API)
|
||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#0ECB81',
|
||||
downColor: '#F6465D',
|
||||
borderUpColor: '#0ECB81',
|
||||
borderDownColor: '#F6465D',
|
||||
wickUpColor: '#0ECB81',
|
||||
wickDownColor: '#F6465D',
|
||||
})
|
||||
|
||||
candlestickSeriesRef.current = candlestickSeries as any
|
||||
|
||||
// 响应式调整
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current && chartRef.current) {
|
||||
chartRef.current.applyOptions({
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart.remove()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ChartWithOrders] Failed to initialize chart:', err)
|
||||
setError('Failed to initialize chart')
|
||||
}
|
||||
}, [height])
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!candlestickSeriesRef.current) {
|
||||
console.log('[ChartWithOrders] Candlestick series not ready yet')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[ChartWithOrders] Loading data for', symbol, interval, 'trader:', traderID)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 1. 获取K线数据
|
||||
console.log('[ChartWithOrders] Fetching kline data...')
|
||||
const klineData = await fetchKlineData(symbol, interval)
|
||||
console.log('[ChartWithOrders] Kline data received:', klineData.length, 'candles')
|
||||
candlestickSeriesRef.current.setData(klineData)
|
||||
|
||||
// 2. 获取订单数据并添加标记
|
||||
if (traderID) {
|
||||
console.log('[ChartWithOrders] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
||||
const orders = await fetchOrders(traderID, symbol)
|
||||
console.log('[ChartWithOrders] Received orders:', orders.length, 'orders')
|
||||
|
||||
if (orders.length === 0) {
|
||||
console.log('[ChartWithOrders] No orders to display')
|
||||
}
|
||||
|
||||
// 过滤掉无效时间戳的订单(小于2024年的时间戳)
|
||||
const minValidTimestamp = new Date('2024-01-01').getTime() / 1000
|
||||
const validOrders = orders.filter(order => {
|
||||
if (order.time < minValidTimestamp) {
|
||||
console.warn('[ChartWithOrders] ⚠️ Skipping order with invalid timestamp:', order.time, '(', new Date(order.time * 1000).toISOString(), ')')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
console.log('[ChartWithOrders] Valid orders:', validOrders.length, 'out of', orders.length)
|
||||
|
||||
// 转换订单为图表标记 - 简洁版:只用 B/S
|
||||
const markers = validOrders.map((order) => {
|
||||
// 使用 side 字段判断买卖(更准确)
|
||||
// side = BUY → 绿色 B
|
||||
// side = SELL → 红色 S
|
||||
const isBuy = order.side === 'BUY'
|
||||
|
||||
return {
|
||||
time: order.time as Time,
|
||||
position: 'belowBar' as const,
|
||||
color: isBuy ? '#0ECB81' : '#F6465D',
|
||||
shape: 'circle' as const,
|
||||
text: isBuy ? 'B' : 'S',
|
||||
price: order.price,
|
||||
size: 1,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[ChartWithOrders] Setting', markers.length, 'markers on chart')
|
||||
|
||||
try {
|
||||
// 使用 v5 API: createSeriesMarkers
|
||||
if (seriesMarkersRef.current) {
|
||||
// 如果已经存在,更新标记
|
||||
seriesMarkersRef.current.setMarkers(markers)
|
||||
} else {
|
||||
// 首次创建标记
|
||||
seriesMarkersRef.current = createSeriesMarkers(candlestickSeriesRef.current, markers)
|
||||
}
|
||||
console.log('[ChartWithOrders] ✅ Markers set successfully!')
|
||||
} catch (err) {
|
||||
console.error('[ChartWithOrders] ❌ Failed to set markers:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动适配视图
|
||||
chartRef.current?.timeScale().fitContent()
|
||||
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Error loading chart data:', err)
|
||||
setError(language === 'zh' ? '加载图表数据失败' : 'Failed to load chart data')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
|
||||
// 自动刷新 - 每30秒更新一次K线数据
|
||||
const refreshInterval = setInterval(() => {
|
||||
loadData()
|
||||
}, 30000) // 30秒
|
||||
|
||||
return () => {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
}, [symbol, interval, traderID, language])
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ background: '#0B0E11', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">📈</span>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
{symbol} {interval}
|
||||
</h3>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '加载中...' : 'Loading...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图表容器 */}
|
||||
<div ref={chartContainerRef} style={{ position: 'relative' }} />
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'rgba(11, 14, 17, 0.9)' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图例说明 */}
|
||||
<div className="flex items-center gap-4 p-4 text-xs" style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold" style={{ color: '#0ECB81' }}>B</span>
|
||||
<span>{language === 'zh' ? 'BUY (买入)' : 'BUY'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold" style={{ color: '#F6465D' }}>S</span>
|
||||
<span>{language === 'zh' ? 'SELL (卖出)' : 'SELL'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
web/src/components/ChartWithOrdersSimple.tsx
Normal file
121
web/src/components/ChartWithOrdersSimple.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { httpClient } from '../lib/httpClient'
|
||||
|
||||
interface ChartWithOrdersSimpleProps {
|
||||
symbol: string
|
||||
interval?: string
|
||||
traderID?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function ChartWithOrdersSimple({
|
||||
symbol = 'BTCUSDT',
|
||||
interval = '5m',
|
||||
traderID,
|
||||
height = 500,
|
||||
}: ChartWithOrdersSimpleProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [klineCount, setKlineCount] = useState(0)
|
||||
const [orderCount, setOrderCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
console.log('[ChartSimple] Loading data for', symbol, interval, 'trader:', traderID)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 从我们自己的服务获取K线数据
|
||||
const limit = 100
|
||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`
|
||||
|
||||
console.log('[ChartSimple] Fetching klines from our service:', klineUrl)
|
||||
const klineResult = await httpClient.get(klineUrl)
|
||||
|
||||
if (!klineResult.success || !klineResult.data) {
|
||||
throw new Error('Failed to fetch klines from our service')
|
||||
}
|
||||
|
||||
console.log('[ChartSimple] Received klines:', klineResult.data.length)
|
||||
setKlineCount(klineResult.data.length)
|
||||
|
||||
// 测试获取订单数据
|
||||
if (traderID) {
|
||||
const tradesUrl = `/api/trades?trader_id=${traderID}&symbol=${symbol}&limit=100`
|
||||
console.log('[ChartSimple] Fetching trades from:', tradesUrl)
|
||||
const tradesResult = await httpClient.get(tradesUrl)
|
||||
|
||||
if (tradesResult.success && tradesResult.data) {
|
||||
console.log('[ChartSimple] Received trades:', tradesResult.data.length)
|
||||
setOrderCount(tradesResult.data.length)
|
||||
} else {
|
||||
console.warn('[ChartSimple] Failed to fetch trades:', tradesResult.message || 'Unknown error', tradesResult)
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
} catch (err: any) {
|
||||
console.error('[ChartSimple] Error:', err)
|
||||
setError(err.message || 'Failed to load data')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [symbol, interval, traderID])
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ background: '#0B0E11', borderRadius: '8px', overflow: 'hidden', minHeight: height }}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">📈</span>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
{symbol} {interval} (测试模式)
|
||||
</h3>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 测试信息 */}
|
||||
<div className="p-8 space-y-4">
|
||||
{error ? (
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>币安K线数据</div>
|
||||
<div className="text-2xl font-bold" style={{ color: '#0ECB81' }}>
|
||||
{klineCount} 根K线
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{traderID && (
|
||||
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>历史订单数据</div>
|
||||
<div className="text-2xl font-bold" style={{ color: '#F0B90B' }}>
|
||||
{orderCount} 笔订单
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>状态</div>
|
||||
<div className="text-lg" style={{ color: '#EAECEF' }}>
|
||||
✅ 数据获取正常,图表组件开发中
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { t, type Language } from '../i18n/translations'
|
||||
interface DecisionCardProps {
|
||||
decision: DecisionRecord
|
||||
language: Language
|
||||
onSymbolClick?: (symbol: string) => void
|
||||
}
|
||||
|
||||
// Action type configuration
|
||||
@@ -42,7 +43,7 @@ function getConfidenceColor(confidence: number | undefined): string {
|
||||
}
|
||||
|
||||
// Single Action Card Component
|
||||
function ActionCard({ action, language }: { action: DecisionAction; language: Language }) {
|
||||
function ActionCard({ action, language, onSymbolClick }: { action: DecisionAction; language: Language; onSymbolClick?: (symbol: string) => void }) {
|
||||
const config = ACTION_CONFIG[action.action] || ACTION_CONFIG.wait
|
||||
const isLong = action.action.includes('long')
|
||||
const isOpen = action.action.includes('open')
|
||||
@@ -60,7 +61,12 @@ function ActionCard({ action, language }: { action: DecisionAction; language: La
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{config.icon}</span>
|
||||
<span className="font-mono font-bold text-lg" style={{ color: '#EAECEF' }}>
|
||||
<span
|
||||
className="font-mono font-bold text-lg cursor-pointer transition-all duration-200 hover:scale-110"
|
||||
style={{ color: '#EAECEF' }}
|
||||
onClick={() => onSymbolClick?.(action.symbol)}
|
||||
title="Click to view chart"
|
||||
>
|
||||
{action.symbol.replace('USDT', '')}
|
||||
</span>
|
||||
<span
|
||||
@@ -211,10 +217,34 @@ function ActionCard({ action, language }: { action: DecisionAction; language: La
|
||||
)
|
||||
}
|
||||
|
||||
export function DecisionCard({ decision, language }: DecisionCardProps) {
|
||||
export function DecisionCard({ decision, language, onSymbolClick }: DecisionCardProps) {
|
||||
const [showSystemPrompt, setShowSystemPrompt] = useState(false)
|
||||
const [showInputPrompt, setShowInputPrompt] = useState(false)
|
||||
const [showCoT, setShowCoT] = useState(false)
|
||||
|
||||
// Copy text to clipboard
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
alert(`${label} copied!`)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download text as file
|
||||
const downloadAsFile = (text: string, filename: string) => {
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-5 transition-all duration-300 hover:translate-y-[-2px]"
|
||||
@@ -258,14 +288,73 @@ export function DecisionCard({ decision, language }: DecisionCardProps) {
|
||||
{decision.decisions && decision.decisions.length > 0 && (
|
||||
<div className="space-y-3 mb-4">
|
||||
{decision.decisions.map((action, index) => (
|
||||
<ActionCard key={`${action.symbol}-${index}`} action={action} language={language} />
|
||||
<ActionCard key={`${action.symbol}-${index}`} action={action} language={language} onSymbolClick={onSymbolClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Sections */}
|
||||
<div className="space-y-2">
|
||||
{/* Input Prompt */}
|
||||
{/* System Prompt */}
|
||||
{decision.system_prompt && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowSystemPrompt(!showSystemPrompt)}
|
||||
className="flex items-center gap-2 text-sm transition-colors w-full justify-between p-2 rounded hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">⚙️</span>
|
||||
<span className="font-semibold" style={{ color: '#a78bfa' }}>
|
||||
System Prompt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(decision.system_prompt, 'System Prompt')
|
||||
}}
|
||||
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||
style={{ background: 'rgba(167, 139, 250, 0.2)', color: '#a78bfa', border: '1px solid rgba(167, 139, 250, 0.3)' }}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<span>📋</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadAsFile(decision.system_prompt, `system-prompt-cycle-${decision.cycle_number}.txt`)
|
||||
}}
|
||||
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||
style={{ background: 'rgba(167, 139, 250, 0.2)', color: '#a78bfa', border: '1px solid rgba(167, 139, 250, 0.3)' }}
|
||||
title="Download as file"
|
||||
>
|
||||
<span>💾</span>
|
||||
</button>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ background: 'rgba(167, 139, 250, 0.15)', color: '#a78bfa' }}
|
||||
>
|
||||
{showSystemPrompt ? t('collapse', language) : t('expand', language)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{showSystemPrompt && (
|
||||
<div
|
||||
className="mt-2 rounded-lg p-4 text-sm font-mono whitespace-pre-wrap max-h-96 overflow-y-auto"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
{decision.system_prompt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User/Input Prompt */}
|
||||
{decision.input_prompt && (
|
||||
<div>
|
||||
<button
|
||||
@@ -275,15 +364,39 @@ export function DecisionCard({ decision, language }: DecisionCardProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">📥</span>
|
||||
<span className="font-semibold" style={{ color: '#60a5fa' }}>
|
||||
{t('inputPrompt', language)}
|
||||
User Prompt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(decision.input_prompt, 'User Prompt')
|
||||
}}
|
||||
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||
style={{ background: 'rgba(96, 165, 250, 0.2)', color: '#60a5fa', border: '1px solid rgba(96, 165, 250, 0.3)' }}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<span>📋</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadAsFile(decision.input_prompt, `user-prompt-cycle-${decision.cycle_number}.txt`)
|
||||
}}
|
||||
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||
style={{ background: 'rgba(96, 165, 250, 0.2)', color: '#60a5fa', border: '1px solid rgba(96, 165, 250, 0.3)' }}
|
||||
title="Download as file"
|
||||
>
|
||||
<span>💾</span>
|
||||
</button>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ background: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
|
||||
>
|
||||
{showInputPrompt ? t('collapse', language) : t('expand', language)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ background: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
|
||||
>
|
||||
{showInputPrompt ? t('collapse', language) : t('expand', language)}
|
||||
</span>
|
||||
</button>
|
||||
{showInputPrompt && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user