522 lines
20 KiB
JavaScript
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>
|
|
);
|
|
}
|