Files
evotraders/frontend/src/components/AppShell.jsx

522 lines
20 KiB
JavaScript

import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react';
import GlobalStyles from '../styles/GlobalStyles';
import Header from './Header.jsx';
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
import StockLogo from './StockLogo.jsx';
import NetValueChart from './NetValueChart.jsx';
import { AGENTS } from '../config/constants';
import { useRuntimeStore } from '../store/runtimeStore';
import { useUIStore } from '../store/uiStore';
import { formatNumber, formatTickerPrice } from '../utils/formatters';
const RoomView = lazy(() => import('./RoomView'));
const AgentFeed = lazy(() => import('./AgentFeed'));
const StatisticsView = lazy(() => import('./StatisticsView'));
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
const TraderView = lazy(() => import('./TraderView.jsx'));
const OpenClawView = lazy(() => import('./OpenClawView.jsx'));
function ViewLoadingFallback({ label = '加载中...' }) {
return (
<div style={{
minHeight: 240,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #000000',
background: '#ffffff',
fontSize: 12,
fontWeight: 700,
letterSpacing: 0.4
}}>
{label}
</div>
);
}
/**
* AppShell - Layout shell containing Header, TickerBar, ViewNavBar, View container, and AgentFeed
*/
export default function AppShell({
// Connection & status
isConnected,
virtualTime,
now,
marketStatus,
serverMode,
marketStatusLabel,
dataSourceLabel,
runtimeSummaryLabel,
isUpdating,
// Handlers
onManualTrigger,
onOpenRuntimeLogs,
onRuntimeSettingsToggle,
// Runtime settings panel props
isRuntimeSettingsOpen,
isRuntimeConfigSaving,
isWatchlistSaving,
runtimeConfigFeedback,
watchlistFeedback,
launchModeDraft,
restoreRunIdDraft,
runtimeHistoryRuns,
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
watchlistDraftSymbols,
watchlistInputValue,
watchlistSuggestions,
onLaunchModeChange,
onRestoreRunIdChange,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onModeChange,
onPollIntervalChange,
onStartDateChange,
onEndDateChange,
onWatchlistInputChange,
onWatchlistInputKeyDown,
onWatchlistAdd,
onWatchlistRemove,
onWatchlistRestoreCurrent,
onWatchlistRestoreDefault,
onWatchlistSuggestionClick,
onLaunchConfigSave,
onRestoreDefaults,
// Ticker and portfolio data
displayTickers,
portfolioData,
rollingTickers,
// Feed data
feed,
bubbles,
bubbleFor,
leaderboard,
// Views data
currentView,
chartTab,
holdings,
trades,
stats,
priceHistoryByTicker,
ohlcHistoryByTicker,
selectedExplainSymbol,
onSelectedExplainSymbolChange,
historySourceByTicker,
explainEventsByTicker,
newsByTicker,
insiderTradesByTicker,
technicalIndicatorsByTicker,
currentDate,
// Stock request handlers
stockRequests,
// Agent request handlers
agentRequests,
agentProfilesByAgent,
// Layout
leftWidth,
isResizing,
onMouseDown,
agentFeedRef
}) {
const containerRef = useRef(null);
const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore();
const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore();
// Resize handler
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e) => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
if (newLeftWidth >= 30 && newLeftWidth <= 85) {
setLeftWidth(newLeftWidth);
}
};
const handleMouseUp = () => setIsResizing(false);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, setIsResizing, setLeftWidth]);
const handleJumpToMessage = (bubble) => {
if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) {
agentFeedRef.current.scrollToMessage(bubble);
}
};
const viewClassName = useMemo(() => {
const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' :
currentView === 'room' ? 'show-room' :
currentView === 'explain' ? 'show-explain' :
currentView === 'chart' ? 'show-chart' :
currentView === 'statistics' ? 'show-statistics' : 'show-openclaw'}`;
return base;
}, [currentView]);
return (
<div className="app">
<GlobalStyles />
{/* Header */}
<div className="header">
<Header />
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
{/* Unified Status Indicator */}
<div className="header-status-inline">
<span className={`status-dot ${isConnected ? (isUpdating ? 'updating' : 'live') : 'offline'}`} />
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'}
</span>
{marketStatus && (
<>
<span className="status-sep">·</span>
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
{marketStatusLabel}
</span>
</>
)}
{dataSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">{dataSourceLabel}</span>
</>
)}
{runtimeSummaryLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest" title="当前运行配置">{runtimeSummaryLabel}</span>
</>
)}
<span className="status-sep">·</span>
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
</div>
{serverMode !== 'backtest' && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{onOpenRuntimeLogs && (
<button
onClick={onOpenRuntimeLogs}
style={{
padding: '6px 12px',
borderRadius: 4,
background: '#FFFFFF',
border: '1px solid #111111',
color: '#111111',
fontSize: '11px',
fontFamily: '"Courier New", monospace',
fontWeight: 700,
cursor: 'pointer',
letterSpacing: '0.4px',
textTransform: 'uppercase'
}}
title="查看当前运行日志"
>
运行日志
</button>
)}
<button
onClick={onManualTrigger}
disabled={!isConnected}
style={{
padding: '6px 12px',
borderRadius: 4,
background: isConnected ? '#111111' : '#8a8a8a',
border: '1px solid #111111',
color: '#FFFFFF',
fontSize: '11px',
fontFamily: '"Courier New", monospace',
fontWeight: 700,
cursor: isConnected ? 'pointer' : 'not-allowed',
letterSpacing: '0.4px',
textTransform: 'uppercase'
}}
title="手动触发一轮分析与交易决策"
>
手动运行
</button>
</div>
)}
<RuntimeSettingsPanel
showTrigger={false}
isOpen={isRuntimeSettingsOpen}
isConnected={isConnected}
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
feedback={runtimeConfigFeedback || watchlistFeedback}
launchMode={launchModeDraft}
restoreRunId={restoreRunIdDraft}
runtimeHistoryRuns={runtimeHistoryRuns}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
initialCash={initialCashDraft}
marginRequirement={marginRequirementDraft}
enableMemory={enableMemoryDraft}
mode={modeDraft}
pollInterval={pollIntervalDraft}
startDate={startDateDraft}
endDate={endDateDraft}
watchlistSymbols={watchlistDraftSymbols}
watchlistInputValue={watchlistInputValue}
watchlistSuggestions={watchlistSuggestions}
onToggle={onRuntimeSettingsToggle}
onClose={() => setIsRuntimeSettingsOpen(false)}
onLaunchModeChange={onLaunchModeChange}
onRestoreRunIdChange={onRestoreRunIdChange}
onScheduleModeChange={onScheduleModeChange}
onIntervalMinutesChange={onIntervalMinutesChange}
onTriggerTimeChange={onTriggerTimeChange}
onMaxCommCyclesChange={onMaxCommCyclesChange}
onInitialCashChange={onInitialCashChange}
onMarginRequirementChange={onMarginRequirementChange}
onEnableMemoryChange={onEnableMemoryChange}
onModeChange={onModeChange}
onPollIntervalChange={onPollIntervalChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onWatchlistInputChange={onWatchlistInputChange}
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
onWatchlistAdd={onWatchlistAdd}
onWatchlistRemove={onWatchlistRemove}
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
onSave={onLaunchConfigSave}
onRestoreDefaults={onRestoreDefaults}
/>
</div>
</div>
{/* Main Content */}
<>
{/* Ticker Bar */}
<div className="ticker-bar">
<div className="ticker-track">
{[0, 1].map((groupIdx) => (
<div key={groupIdx} className="ticker-group">
{displayTickers.map(ticker => (
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
<StockLogo ticker={ticker.symbol} size={16} />
<span className="ticker-symbol">{ticker.symbol}</span>
<span className="ticker-price">
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
{ticker.price !== null && ticker.price !== undefined
? `$${formatTickerPrice(ticker.price)}` : '-'}
</span>
</span>
<span className={`ticker-change ${
ticker.change === null || ticker.change === undefined
? '' : ticker.change >= 0 ? 'positive' : 'negative'
}`}>
{ticker.change !== null && ticker.change !== undefined
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'}
</span>
</div>
))}
</div>
))}
</div>
<div className="portfolio-value">
<span className="portfolio-label">投资组合</span>
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
</div>
</div>
<div className="main-container" ref={containerRef}>
{/* Left Panel */}
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
<div className="chart-section">
<div className="view-container">
<div className="view-nav-bar">
<button
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
onClick={() => setCurrentView('traders')}
>
交易员
</button>
<button
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
onClick={() => setCurrentView('room')}
>
交易室
</button>
<button
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
onClick={() => setCurrentView('explain')}
>
个股分析
</button>
<button
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
onClick={() => setCurrentView('chart')}
>
业绩图表
</button>
<button
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
onClick={() => setCurrentView('statistics')}
>
统计
</button>
<button
className={`view-nav-btn ${currentView === 'openclaw' ? 'active' : ''}`}
onClick={() => setCurrentView('openclaw')}
>
OpenClaw
</button>
</div>
<div className={viewClassName}>
{/* Traders View */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
<TraderView {...agentRequests} />
</Suspense>
</div>
{/* Room View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
<RoomView
bubbles={bubbles}
bubbleFor={bubbleFor}
leaderboard={leaderboard}
agentProfilesByAgent={agentProfilesByAgent}
feed={feed}
onJumpToMessage={handleJumpToMessage}
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
/>
</Suspense>
</div>
{/* Stock Explain View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
<StockExplainView
tickers={displayTickers}
holdings={holdings}
trades={trades}
leaderboard={leaderboard}
feed={feed}
priceHistoryByTicker={priceHistoryByTicker}
ohlcHistoryByTicker={ohlcHistoryByTicker}
selectedSymbol={selectedExplainSymbol}
onSelectedSymbolChange={onSelectedExplainSymbolChange}
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
onRequestHistory={stockRequests?.requestStockHistory}
onRequestExplainEvents={stockRequests?.requestStockExplainEvents}
onRequestNews={stockRequests?.requestStockNews}
onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
onRequestStory={stockRequests?.requestStockStory}
onRequestInsiderTrades={stockRequests?.requestStockInsiderTrades}
onRequestTechnicalIndicators={stockRequests?.requestStockTechnicalIndicators}
currentDate={currentDate}
onRequestSimilarDays={stockRequests?.requestStockSimilarDays}
onRequestStockEnrich={stockRequests?.requestStockEnrich}
/>
</Suspense>
</div>
{/* Chart View Panel */}
<div className="view-panel">
<div className="chart-container">
<div className="chart-tabs-floating">
<button
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
onClick={() => setChartTab('all')}
>
日线
</button>
</div>
{currentView === 'chart' ? (
<NetValueChart
equity={portfolioData.equity}
baseline={portfolioData.baseline}
baseline_vw={portfolioData.baseline_vw}
momentum={portfolioData.momentum}
strategies={portfolioData.strategies}
equity_return={portfolioData.equity_return}
baseline_return={portfolioData.baseline_return}
baseline_vw_return={portfolioData.baseline_vw_return}
momentum_return={portfolioData.momentum_return}
chartTab={chartTab}
virtualTime={virtualTime}
/>
) : (
<div style={{ height: '100%', minHeight: 320 }} />
)}
</div>
</div>
{/* Statistics View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
<StatisticsView
trades={trades}
holdings={holdings}
stats={stats}
portfolioData={portfolioData}
baseline_vw={portfolioData.baseline_vw}
equity={portfolioData.equity}
leaderboard={leaderboard}
/>
</Suspense>
</div>
{/* OpenClaw View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载 OpenClaw 视图..." />}>
<OpenClawView />
</Suspense>
</div>
</div>
</div>
</div>
</div>
{/* Resizer */}
<div className={`resizer ${isResizing ? 'resizing' : ''}`} onMouseDown={onMouseDown} />
{/* Right Panel: Agent Feed */}
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} agentProfilesByAgent={agentProfilesByAgent} />
</Suspense>
</div>
</div>
</>
</div>
);
}