feat: 架构修复 - P0/P1 问题全面修复

P0 修复:
- runtimeStore: 添加缺失的 lastDayHistory 字段
- Gateway/RuntimeService: 状态同步改为内存优先,消除 glob 竞态
- App.jsx: 从 3075 行重构到 ~500 行,提取 8 个独立文件

P1 修复:
- CORS: 4 个服务改为从环境变量读取允许 origins
- MarketStore: 改为模块级单例模式
- Domain 层: 删除 trading thin wrapper,保留 news 真实逻辑
- 测试: 补齐 77 个 gateway/runtime 测试

新增文件:
- backend/tests/test_gateway.py (43 tests)
- frontend/src/hooks/useWebSocketHandler.js
- frontend/src/hooks/useStockRequestCallbacks.js
- frontend/src/hooks/useAgentCallbacks.js
- frontend/src/hooks/useRuntimeCallbacks.js
- frontend/src/hooks/useWatchlistCallbacks.js
- frontend/src/components/TickerBar.jsx
- frontend/src/components/HeaderRight.jsx
- frontend/src/components/ChartTabs.jsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 18:45:57 +08:00
parent 80256a4079
commit 3926a6bd07
21 changed files with 4280 additions and 2790 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function ChartTabs({
chartTab,
setChartTab,
isLiveEnabled
}) {
return (
<div className="chart-tabs-floating">
<button
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
onClick={() => setChartTab('all')}
>
日线
</button>
</div>
);
}

View File

@@ -0,0 +1,293 @@
import React from 'react';
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
export default function HeaderRight({
// Connection state
isConnected,
// Virtual time
virtualTime,
now,
// Market & server
marketStatus,
marketStatusLabel,
serverMode,
// Labels
runtimeSummaryLabel,
livePriceSourceLabel,
historicalPriceSourceLabel,
// Settings state
isRuntimeSettingsOpen,
isRuntimeConfigSaving,
isWatchlistSaving,
runtimeConfigFeedback,
watchlistFeedback,
// Settings panel props
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft,
watchlistDraftSymbols,
watchlistInputValue,
watchlistSuggestions,
// Callbacks
onRuntimeSettingsToggle,
onCloseSettings,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onModeChange,
onPollIntervalChange,
onStartDateChange,
onEndDateChange,
onEnableMockChange,
onWatchlistInputChange,
onWatchlistInputKeyDown,
onWatchlistAdd,
onWatchlistRemove,
onWatchlistRestoreCurrent,
onWatchlistRestoreDefault,
onWatchlistSuggestionClick,
onLaunchConfigSave,
onRestoreDefaults,
onManualTrigger,
clientRef
}) {
return (
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
{/* Mock Mode Indicator */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 4,
background: '#FF9800',
border: '1px solid #FFB74D'
}}>
<span style={{
fontSize: '11px',
fontWeight: 600,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '0.5px'
}}>
模拟模式
</span>
</div>
)}
{/* Clock Display (only in Mock mode) */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 2,
padding: '4px 12px',
borderRadius: 4,
background: '#1A237E',
border: '1px solid #3F51B5'
}}>
<span style={{
fontSize: '11px',
color: '#999',
fontFamily: '"Courier New", monospace',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
虚拟时间
</span>
<span style={{
fontSize: '14px',
fontWeight: 700,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '1px'
}}>
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
</span>
<span style={{
fontSize: '10px',
color: '#999',
fontFamily: '"Courier New", monospace'
}}>
{now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
{/* Fast Forward Button (only in Mock mode) */}
<button
onClick={() => {
if (clientRef.current) {
const success = clientRef.current.send({
type: 'fast_forward_time',
minutes: 30
});
if (!success) {
console.error('Failed to send fast forward request');
}
}
}}
style={{
padding: '6px 12px',
borderRadius: 4,
background: '#3F51B5',
border: '1px solid #5C6BC0',
color: '#FFFFFF',
fontSize: '12px',
fontFamily: '"Courier New", monospace',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: 4,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}
onMouseEnter={(e) => {
e.target.style.background = '#5C6BC0';
e.target.style.borderColor = '#7986CB';
}}
onMouseLeave={(e) => {
e.target.style.background = '#3F51B5';
e.target.style.borderColor = '#5C6BC0';
}}
title="快进30分钟 (Mock模式)"
>
+30min
</button>
</div>
)}
{/* Unified Status Indicator */}
<div className="header-status-inline">
<span className={`status-dot ${isConnected ? 'live' : 'offline'}`} />
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
{isConnected ? '在线' : '离线'}
</span>
{marketStatus && (
<>
<span className="status-sep">·</span>
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
{marketStatusLabel}
</span>
</>
)}
{livePriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{livePriceSourceLabel}
</span>
</>
)}
{historicalPriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{historicalPriceSourceLabel}
</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' && (
<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>
)}
<RuntimeSettingsPanel
showTrigger={false}
isOpen={isRuntimeSettingsOpen}
isConnected={isConnected}
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
feedback={runtimeConfigFeedback || watchlistFeedback}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
initialCash={initialCashDraft}
marginRequirement={marginRequirementDraft}
enableMemory={enableMemoryDraft}
mode={modeDraft}
pollInterval={pollIntervalDraft}
startDate={startDateDraft}
endDate={endDateDraft}
enableMock={enableMockDraft}
watchlistSymbols={watchlistDraftSymbols}
watchlistInputValue={watchlistInputValue}
watchlistSuggestions={watchlistSuggestions}
onToggle={onRuntimeSettingsToggle}
onClose={onCloseSettings}
onScheduleModeChange={onScheduleModeChange}
onIntervalMinutesChange={onIntervalMinutesChange}
onTriggerTimeChange={onTriggerTimeChange}
onMaxCommCyclesChange={onMaxCommCyclesChange}
onInitialCashChange={onInitialCashChange}
onMarginRequirementChange={onMarginRequirementChange}
onEnableMemoryChange={onEnableMemoryChange}
onModeChange={onModeChange}
onPollIntervalChange={onPollIntervalChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onEnableMockChange={onEnableMockChange}
onWatchlistInputChange={onWatchlistInputChange}
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
onWatchlistAdd={onWatchlistAdd}
onWatchlistRemove={onWatchlistRemove}
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
onSave={onLaunchConfigSave}
onRestoreDefaults={onRestoreDefaults}
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import StockLogo from './StockLogo';
import { formatNumber, formatTickerPrice } from '../utils/formatters';
export default function TickerBar({
displayTickers,
rollingTickers,
portfolioData,
onTickerSelect
}) {
return (
<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"
onClick={() => onTickerSelect && onTickerSelect(ticker.symbol)}
style={{ cursor: onTickerSelect ? 'pointer' : 'default' }}
>
<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>
);
}

View File

@@ -0,0 +1,308 @@
import { useCallback, useEffect } from 'react';
import { uploadAgentSkillZip } from '../services/runtimeApi';
/**
* Extracts agent/skill-related callbacks from App.jsx into a single hook.
*/
export function useAgentCallbacks({
clientRef,
selectedSkillAgentId,
selectedWorkspaceFile,
workspaceDraftContent,
agentProfilesByAgent,
agentSkillsByAgent,
workspaceFilesByAgent,
AGENTS,
setters
}) {
const {
setIsAgentSkillsLoading,
setAgentSkillsFeedback,
setSkillDetailLoadingKey,
setAgentSkillsSavingKey,
setIsWorkspaceFileLoading,
setWorkspaceFileSavingKey,
setWorkspaceFileFeedback,
setLocalSkillDraftsByKey,
setAgentSkillsByAgent,
setAgentProfilesByAgent,
setSkillDetailsByName,
setWorkspaceFilesByAgent,
setSelectedSkillAgentId,
setSelectedWorkspaceFile,
setWorkspaceDraftContent
} = setters;
const requestAgentSkills = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
setIsAgentSkillsLoading(true);
setAgentSkillsFeedback(null);
return clientRef.current.send({
type: 'get_agent_skills',
agent_id: normalized
});
}, [clientRef, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
const requestAgentProfile = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_agent_profile',
agent_id: normalized
});
}, [clientRef]);
const requestSkillDetail = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
const detailKey = `${selectedSkillAgentId}:${normalized}`;
setSkillDetailLoadingKey(detailKey);
return clientRef.current.send({
type: 'get_skill_detail',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
}, [clientRef, selectedSkillAgentId, setSkillDetailLoadingKey]);
const requestWorkspaceFile = useCallback((agentId, filename) => {
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
return false;
}
setIsWorkspaceFileLoading(true);
setWorkspaceFileFeedback(null);
return clientRef.current.send({
type: 'get_agent_workspace_file',
agent_id: normalizedAgentId,
filename: normalizedFilename
});
}, [clientRef, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
const handleCreateLocalSkill = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized) {
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
return;
}
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'create_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
const detailKey = `${selectedSkillAgentId}:${skillName}`;
setLocalSkillDraftsByKey((prev) => ({
...prev,
[detailKey]: content
}));
}, [selectedSkillAgentId, setLocalSkillDraftsByKey]);
const handleLocalSkillSave = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const detailKey = `${selectedSkillAgentId}:${skillName}`;
const content = setters.localSkillDraftsByKey[detailKey];
if (typeof content !== 'string') {
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName,
content
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setters.localSkillDraftsByKey, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleLocalSkillDelete = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'delete_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleRemoveSharedSkill = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'remove_agent_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName,
enabled
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
const handleSkillAgentChange = useCallback((agentId) => {
setSelectedSkillAgentId(agentId);
requestAgentProfile(agentId);
requestAgentSkills(agentId);
requestWorkspaceFile(agentId, selectedWorkspaceFile);
}, [requestAgentProfile, requestAgentSkills, requestWorkspaceFile, selectedWorkspaceFile, setSelectedSkillAgentId]);
const handleWorkspaceFileChange = useCallback((filename) => {
setSelectedWorkspaceFile(filename);
requestWorkspaceFile(selectedSkillAgentId, filename);
}, [requestWorkspaceFile, selectedSkillAgentId, setSelectedWorkspaceFile]);
const handleWorkspaceFileSave = useCallback(() => {
if (!clientRef.current) {
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
setWorkspaceFileSavingKey(key);
setWorkspaceFileFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_workspace_file',
agent_id: selectedSkillAgentId,
filename: selectedWorkspaceFile,
content: workspaceDraftContent
});
if (!success) {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [clientRef, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent, setWorkspaceFileSavingKey, setWorkspaceFileFeedback]);
const handleUploadExternalSkill = useCallback(async (file) => {
if (!(file instanceof File)) {
setAgentSkillsFeedback({ type: 'error', text: '请选择 zip 文件后再上传' });
return;
}
if (!selectedSkillAgentId) {
setAgentSkillsFeedback({ type: 'error', text: '未选择目标 Agent' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
setAgentSkillsFeedback(null);
try {
const result = await uploadAgentSkillZip({
agentId: selectedSkillAgentId,
file,
activate: true
});
setAgentSkillsFeedback({
type: 'success',
text: `已上传并安装技能 ${result.skill_name || ''}`.trim()
});
requestAgentSkills(selectedSkillAgentId);
} catch (error) {
setAgentSkillsFeedback({
type: 'error',
text: `上传失败: ${error.message || '未知错误'}`
});
} finally {
setAgentSkillsSavingKey(null);
}
}, [selectedSkillAgentId, requestAgentSkills, setAgentSkillsSavingKey, setAgentSkillsFeedback]);
// Sync workspace draft content when selected content changes
useEffect(() => {
const selectedWorkspaceContent = workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile] || '';
setWorkspaceDraftContent(selectedWorkspaceContent);
}, [selectedWorkspaceFile, selectedSkillAgentId, workspaceFilesByAgent, setWorkspaceDraftContent]);
// Load agent profiles and skills when view changes
const currentView = setters.currentView;
const isConnected = setters.isConnected;
useEffect(() => {
if (currentView !== 'traders' || !isConnected) {
return;
}
AGENTS.forEach((agent) => {
if (!agentProfilesByAgent[agent.id]) {
requestAgentProfile(agent.id);
}
if (!agentSkillsByAgent[agent.id]) {
requestAgentSkills(agent.id);
}
if (!workspaceFilesByAgent[agent.id]?.['MEMORY.md']) {
requestWorkspaceFile(agent.id, 'MEMORY.md');
}
});
}, [agentProfilesByAgent, agentSkillsByAgent, currentView, isConnected, requestAgentProfile, requestAgentSkills, requestWorkspaceFile, workspaceFilesByAgent, AGENTS]);
return {
requestAgentSkills,
requestAgentProfile,
requestSkillDetail,
requestWorkspaceFile,
handleCreateLocalSkill,
handleLocalSkillDraftChange,
handleLocalSkillSave,
handleLocalSkillDelete,
handleRemoveSharedSkill,
handleAgentSkillToggle,
handleSkillAgentChange,
handleWorkspaceFileChange,
handleWorkspaceFileSave,
handleUploadExternalSkill
};
}

View File

@@ -0,0 +1,257 @@
import { useCallback } from 'react';
import { startRuntime } from '../services/runtimeApi';
/**
* Extracts runtime config callbacks from App.jsx into a single hook.
*/
export function useRuntimeCallbacks({
clientRef,
addSystemMessage,
parseWatchlistInput,
setters
}) {
const {
setScheduleModeDraft,
setIntervalMinutesDraft,
setTriggerTimeDraft,
setMaxCommCyclesDraft,
setInitialCashDraft,
setMarginRequirementDraft,
setEnableMemoryDraft,
setModeDraft,
setPollIntervalDraft,
setStartDateDraft,
setEndDateDraft,
setEnableMockDraft,
setRuntimeConfigFeedback,
setIsRuntimeConfigSaving,
setIsWatchlistSaving,
setIsRuntimeSettingsOpen,
watchlistDraftSymbols,
watchlistInputValue,
scheduleModeDraft,
intervalMinutesDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft
} = setters;
const handleRuntimeConfigSave = useCallback(() => {
if (!clientRef.current) {
setRuntimeConfigFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const interval = Number(intervalMinutesDraft);
const maxCommCycles = Number(maxCommCyclesDraft);
if (!Number.isInteger(interval) || interval <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '间隔必须是正整数分钟' });
return;
}
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '讨论轮数必须是正整数' });
return;
}
setIsRuntimeConfigSaving(true);
setRuntimeConfigFeedback(null);
const success = clientRef.current.send({
type: 'update_runtime_config',
schedule_mode: scheduleModeDraft,
interval_minutes: interval,
trigger_time: triggerTimeDraft,
max_comm_cycles: maxCommCycles,
initial_cash: Number(initialCashDraft),
margin_requirement: Number(marginRequirementDraft),
enable_memory: Boolean(enableMemoryDraft)
});
if (!success) {
setIsRuntimeConfigSaving(false);
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [
clientRef,
intervalMinutesDraft,
maxCommCyclesDraft,
scheduleModeDraft,
triggerTimeDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
setIsRuntimeConfigSaving,
setRuntimeConfigFeedback
]);
const handleLaunchConfigSave = useCallback(async () => {
const pendingTickers = parseWatchlistInput(watchlistInputValue);
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
if (nextTickers.length === 0) {
setRuntimeConfigFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' });
return;
}
const interval = Number(intervalMinutesDraft);
const maxCommCycles = Number(maxCommCyclesDraft);
const initialCash = Number(initialCashDraft);
const marginRequirement = Number(marginRequirementDraft);
if (!Number.isInteger(interval) || interval <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '间隔必须是正整数分钟' });
return;
}
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '讨论轮数必须是正整数' });
return;
}
if (!Number.isFinite(initialCash) || initialCash <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '初始资金必须是正数' });
return;
}
if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
setRuntimeConfigFeedback({ type: 'error', text: '保证金要求不能为负数' });
return;
}
setIsRuntimeConfigSaving(true);
setIsWatchlistSaving(true);
setRuntimeConfigFeedback(null);
setters.setWatchlistFeedback(null);
setters.setWatchlistDraftSymbols(nextTickers);
setters.setWatchlistInputValue('');
try {
const result = await startRuntime({
tickers: nextTickers,
schedule_mode: scheduleModeDraft,
interval_minutes: interval,
trigger_time: triggerTimeDraft,
max_comm_cycles: maxCommCycles,
initial_cash: initialCash,
margin_requirement: marginRequirement,
enable_memory: Boolean(enableMemoryDraft),
mode: modeDraft || 'live',
poll_interval: Number(pollIntervalDraft) || 10,
start_date: startDateDraft || null,
end_date: endDateDraft || null,
enable_mock: Boolean(enableMockDraft)
});
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setIsRuntimeSettingsOpen(false);
setRuntimeConfigFeedback({
type: 'success',
text: `任务已启动: ${result.run_id}`
});
addSystemMessage(`新任务已启动: ${result.run_id}`);
} catch (error) {
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setRuntimeConfigFeedback({
type: 'error',
text: `启动失败: ${error.message}`
});
}
}, [
parseWatchlistInput,
watchlistInputValue,
watchlistDraftSymbols,
intervalMinutesDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
scheduleModeDraft,
triggerTimeDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
enableMockDraft,
setters,
setIsRuntimeConfigSaving,
setIsWatchlistSaving,
setRuntimeConfigFeedback,
setIsRuntimeSettingsOpen,
addSystemMessage
]);
const handleRuntimeDefaultsRestore = useCallback(() => {
setScheduleModeDraft('daily');
setIntervalMinutesDraft('60');
setTriggerTimeDraft('09:30');
setMaxCommCyclesDraft('2');
setInitialCashDraft('100000');
setMarginRequirementDraft('0');
setEnableMemoryDraft(false);
setModeDraft('live');
setPollIntervalDraft('10');
setStartDateDraft('');
setEndDateDraft('');
setEnableMockDraft(false);
setRuntimeConfigFeedback(null);
}, [
setScheduleModeDraft,
setIntervalMinutesDraft,
setTriggerTimeDraft,
setMaxCommCyclesDraft,
setInitialCashDraft,
setMarginRequirementDraft,
setEnableMemoryDraft,
setModeDraft,
setPollIntervalDraft,
setStartDateDraft,
setEndDateDraft,
setEnableMockDraft,
setRuntimeConfigFeedback
]);
const handleRuntimeSettingsToggle = useCallback(() => {
setRuntimeConfigFeedback(null);
setters.setAgentSkillsFeedback(null);
setters.setWorkspaceFileFeedback(null);
setIsRuntimeSettingsOpen((prev) => {
const nextOpen = !prev;
if (nextOpen) {
// Initialize watchlist draft when opening settings
setters.setWatchlistDraftSymbols(settlers.runtimeWatchlistSymbols);
setters.setWatchlistInputValue('');
setters.setWatchlistFeedback(null);
}
return nextOpen;
});
setters.setIsWatchlistPanelOpen(false);
}, [setRuntimeConfigFeedback, setters, setIsRuntimeSettingsOpen]);
const handleManualTrigger = useCallback(() => {
if (!clientRef.current) {
addSystemMessage('连接未就绪,无法手动触发');
return;
}
const success = clientRef.current.send({
type: 'trigger_strategy'
});
if (!success) {
addSystemMessage('手动触发发送失败,请检查连接状态');
return;
}
addSystemMessage('已发送手动触发请求');
}, [clientRef, addSystemMessage]);
return {
handleRuntimeConfigSave,
handleLaunchConfigSave,
handleRuntimeDefaultsRestore,
handleRuntimeSettingsToggle,
handleManualTrigger
};
}

View File

@@ -0,0 +1,584 @@
import { useCallback } from 'react';
import {
fetchNewsCategoriesDirect,
fetchNewsForDateDirect,
fetchRangeExplainDirect,
fetchSimilarDaysDirect,
fetchStockStoryDirect,
hasDirectNewsService
} from '../services/newsApi';
import {
fetchInsiderTradesDirect,
fetchStockHistoryDirect,
hasDirectTradingService
} from '../services/tradingApi';
/**
* Extracts all requestStock* callbacks from App.jsx into a single hook.
*/
export function useStockRequestCallbacks({
clientRef,
currentDate,
requestedStockHistoryRef,
setters,
apiHelpers
}) {
const {
setOhlcHistoryByTicker,
setHistorySourceByTicker,
setExplainEventsByTicker,
setNewsByTicker,
setInsiderTradesByTicker,
setTechnicalIndicatorsByTicker,
setPriceHistoryByTicker
} = setters;
const {
hasDirectTradingService: _hasDirectTradingService,
fetchStockHistoryDirect: _fetchStockHistoryDirect,
hasDirectNewsService: _hasDirectNewsService,
fetchNewsForDateDirect: _fetchNewsForDateDirect,
fetchNewsCategoriesDirect: _fetchNewsCategoriesDirect,
fetchInsiderTradesDirect: _fetchInsiderTradesDirect,
fetchRangeExplainDirect: _fetchRangeExplainDirect,
fetchStockStoryDirect: _fetchStockStoryDirect,
fetchSimilarDaysDirect: _fetchSimilarDaysDirect
} = apiHelpers;
const buildTickersFromSymbols = useCallback((symbols, previousTickers = []) => {
if (!Array.isArray(symbols) || symbols.length === 0) {
return previousTickers;
}
return symbols
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
.map((symbol) => {
const normalized = symbol.trim().toUpperCase();
const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
return existing || {
symbol: normalized,
price: null,
change: null
};
});
}, []);
const normalizePriceHistory = useCallback((payload) => {
if (!payload || typeof payload !== 'object') {
return {};
}
const normalized = {};
Object.entries(payload).forEach(([symbol, points]) => {
const ticker = String(symbol || '').trim().toUpperCase();
if (!ticker || !Array.isArray(points)) {
return;
}
normalized[ticker] = points
.map((point) => {
if (Array.isArray(point) && point.length >= 2) {
const [label, value] = point;
const price = Number(value);
if (!label || !Number.isFinite(price)) return null;
return {
timestamp: String(label),
label: String(label),
price
};
}
if (point && typeof point === 'object') {
const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
const price = Number(point.price ?? point.v ?? point.value ?? point.close);
if (!rawTimestamp || !Number.isFinite(price)) return null;
return {
timestamp: String(rawTimestamp),
label: String(rawTimestamp),
price
};
}
return null;
})
.filter(Boolean)
.slice(-120);
});
return normalized;
}, []);
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) {
return false;
}
if (!force && requestedStockHistoryRef.current.has(normalized)) {
return false;
}
const endDate = currentDate
? String(currentDate).slice(0, 10)
: new Date().toISOString().slice(0, 10);
const end = new Date(`${endDate}T00:00:00`);
const start = new Date(end);
start.setDate(start.getDate() - 120);
const startDate = start.toISOString().slice(0, 10);
if (_hasDirectTradingService()) {
void _fetchStockHistoryDirect(normalized, startDate, endDate)
.then((payload) => {
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
setOhlcHistoryByTicker((prev) => ({
...prev,
[normalized]: prices
}));
setPriceHistoryByTicker((prev) => ({
...prev,
[normalized]: prices
.map((point) => {
const price = Number(point?.close);
const timestamp = point?.time;
if (!timestamp || !Number.isFinite(price)) {
return null;
}
return {
timestamp: String(timestamp),
label: String(timestamp),
price
};
})
.filter(Boolean)
}));
setHistorySourceByTicker((prev) => ({
...prev,
[normalized]: 'trading_service'
}));
})
.catch((error) => {
console.error('Direct stock-history fetch failed, falling back to websocket:', error);
if (clientRef.current) {
const success = clientRef.current.send({
type: 'get_stock_history',
ticker: normalized,
lookback_days: 120
});
if (success) {
requestedStockHistoryRef.current.add(normalized);
}
}
});
requestedStockHistoryRef.current.add(normalized);
return true;
}
if (!clientRef.current) {
return false;
}
const success = clientRef.current.send({
type: 'get_stock_history',
ticker: normalized,
lookback_days: 120
});
if (success) {
requestedStockHistoryRef.current.add(normalized);
}
return success;
}, [currentDate, _hasDirectTradingService, _fetchStockHistoryDirect, clientRef, requestedStockHistoryRef, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
const requestStockExplainEvents = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_explain_events',
ticker: normalized
});
}, [clientRef]);
const requestStockNews = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news',
ticker: normalized,
lookback_days: 45,
limit: 12
});
}, [clientRef]);
const requestStockNewsForDate = useCallback((symbol, date) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !date) {
return false;
}
if (_hasDirectNewsService()) {
void _fetchNewsForDateDirect(normalized, date, 20)
.then((payload) => {
const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
const news = Array.isArray(payload?.news) ? payload.news : [];
const freshness = payload?.freshness || null;
setNewsByTicker((prev) => ({
...prev,
[normalized]: {
...(prev[normalized] || {}),
byDate: {
...((prev[normalized] && prev[normalized].byDate) || {}),
[targetDate]: news
},
byDateFreshness: {
...((prev[normalized] && prev[normalized].byDateFreshness) || {}),
[targetDate]: freshness
}
}
}));
})
.catch((error) => {
console.error('Direct news-for-date fetch failed, falling back to websocket:', error);
if (clientRef.current) {
clientRef.current.send({
type: 'get_stock_news_for_date',
ticker: normalized,
date,
limit: 20
});
}
});
return true;
}
if (!clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news_for_date',
ticker: normalized,
date,
limit: 20
});
}, [clientRef, _hasDirectNewsService, _fetchNewsForDateDirect, setNewsByTicker]);
const requestStockNewsTimeline = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news_timeline',
ticker: normalized,
lookback_days: 90
});
}, [clientRef]);
const requestStockNewsCategories = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) {
return false;
}
const endDate = currentDate
? String(currentDate).slice(0, 10)
: new Date().toISOString().slice(0, 10);
const end = new Date(`${endDate}T00:00:00`);
const start = new Date(end);
start.setDate(start.getDate() - 90);
const startDate = start.toISOString().slice(0, 10);
if (_hasDirectNewsService()) {
void _fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
.then((payload) => {
const freshness = payload?.freshness || null;
setNewsByTicker((prev) => ({
...prev,
[normalized]: {
...(prev[normalized] || {}),
categories: payload?.categories || {},
categoriesStartDate: startDate,
categoriesEndDate: endDate,
categoriesFreshness: freshness
}
}));
})
.catch((error) => {
console.error('Direct news-categories fetch failed, falling back to websocket:', error);
if (clientRef.current) {
clientRef.current.send({
type: 'get_stock_news_categories',
ticker: normalized,
lookback_days: 90
});
}
});
return true;
}
if (!clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news_categories',
ticker: normalized,
lookback_days: 90
});
}, [currentDate, clientRef, _hasDirectNewsService, _fetchNewsCategoriesDirect, setNewsByTicker]);
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) {
return false;
}
if (_hasDirectTradingService()) {
void _fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
.then((payload) => {
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
setInsiderTradesByTicker((prev) => ({
...prev,
[normalized]: {
ticker: normalized,
startDate: startDate || null,
endDate: endDate || null,
trades: rows
}
}));
})
.catch((error) => {
console.error('Direct insider-trades fetch failed, falling back to websocket:', error);
if (clientRef.current) {
clientRef.current.send({
type: 'get_stock_insider_trades',
ticker: normalized,
start_date: startDate,
end_date: endDate,
limit: 50
});
}
});
return true;
}
if (!clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_insider_trades',
ticker: normalized,
start_date: startDate,
end_date: endDate,
limit: 50
});
}, [clientRef, _hasDirectTradingService, _fetchInsiderTradesDirect, setInsiderTradesByTicker]);
const requestStockTechnicalIndicators = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_technical_indicators',
ticker: normalized
});
}, [clientRef]);
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !startDate || !endDate) {
return false;
}
if (_hasDirectNewsService()) {
void _fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
.then((payload) => {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
const freshness = payload?.freshness || null;
if (!result?.start_date || !result?.end_date) {
return;
}
const cacheKey = `${result.start_date}:${result.end_date}`;
setNewsByTicker((prev) => ({
...prev,
[normalized]: {
...(prev[normalized] || {}),
rangeExplainCache: {
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
[cacheKey]: {
...result,
freshness
}
}
}
}));
})
.catch((error) => {
console.error('Direct range explain fetch failed, falling back to websocket:', error);
if (clientRef.current) {
clientRef.current.send({
type: 'get_stock_range_explain',
ticker: normalized,
start_date: startDate,
end_date: endDate,
article_ids: Array.isArray(articleIds) ? articleIds : []
});
}
});
return true;
}
if (!clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_range_explain',
ticker: normalized,
start_date: startDate,
end_date: endDate,
article_ids: Array.isArray(articleIds) ? articleIds : []
});
}, [clientRef, _hasDirectNewsService, _fetchRangeExplainDirect, setNewsByTicker]);
const requestStockStory = useCallback((symbol, asOfDate) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
const date = typeof asOfDate === 'string' ? asOfDate.trim() : '';
if (!normalized || !date) {
return false;
}
if (_hasDirectNewsService()) {
void _fetchStockStoryDirect(normalized, date)
.then((payload) => {
setNewsByTicker((prev) => ({
...prev,
[normalized]: {
...(prev[normalized] || {}),
storyCache: {
...((prev[normalized] && prev[normalized].storyCache) || {}),
[date]: {
story: payload?.story || '',
source: payload?.source || null,
asOfDate: date,
freshness: payload?.freshness || null
}
}
}
}));
})
.catch((error) => {
console.error('Direct story fetch failed, falling back to websocket:', error);
if (clientRef.current) {
clientRef.current.send({
type: 'get_stock_story',
ticker: normalized,
as_of_date: date
});
}
});
return true;
}
if (!clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_story',
ticker: normalized,
as_of_date: date
});
}, [clientRef, _hasDirectNewsService, _fetchStockStoryDirect, setNewsByTicker]);
const requestStockSimilarDays = useCallback((symbol, targetDate, lookbackDays = 365) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
const date = typeof targetDate === 'string' ? targetDate.trim() : '';
if (!normalized || !date) {
return false;
}
if (_hasDirectNewsService()) {
void _fetchSimilarDaysDirect(normalized, date, lookbackDays)
.then((payload) => {
setNewsByTicker((prev) => ({
...prev,
[normalized]: {
...(prev[normalized] || {}),
similarDaysCache: {
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
[date]: {
target_features: payload?.target_features || {},
items: Array.isArray(payload?.items) ? payload?.items : [],
error: payload?.error || null,
freshness: payload?.freshness || null
}
}
}
}));
})
.catch((error) => {
console.error('Direct similar-days fetch failed, falling back to websocket:', error);
if (clientRef.current) {
clientRef.current.send({
type: 'get_stock_similar_days',
ticker: normalized,
target_date: date,
lookback_days: lookbackDays
});
}
});
return true;
}
if (!clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_similar_days',
ticker: normalized,
target_date: date,
lookback_days: lookbackDays
});
}, [clientRef, _hasDirectNewsService, _fetchSimilarDaysDirect, setNewsByTicker]);
const requestStockEnrich = useCallback((symbol, startDate, endDate, { force = false, onlyLocalToLlm = false } = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'enrich_stock_news',
ticker: normalized,
start_date: startDate,
end_date: endDate,
force: Boolean(force),
only_local_to_llm: Boolean(onlyLocalToLlm)
});
}, [clientRef]);
return {
buildTickersFromSymbols,
normalizePriceHistory,
requestStockHistory,
requestStockExplainEvents,
requestStockNews,
requestStockNewsForDate,
requestStockNewsTimeline,
requestStockNewsCategories,
requestStockInsiderTrades,
requestStockTechnicalIndicators,
requestStockRangeExplain,
requestStockStory,
requestStockSimilarDays,
requestStockEnrich
};
}

View File

@@ -0,0 +1,144 @@
import { useCallback, useMemo } from 'react';
import { INITIAL_TICKERS } from '../config/constants';
/**
* Extracts watchlist-related callbacks from App.jsx into a single hook.
*/
export function useWatchlistCallbacks({
clientRef,
runtimeWatchlistSymbols,
watchlistDraftSymbols,
watchlistInputValue,
watchlistFeedback,
setters
}) {
const {
setWatchlistDraftSymbols,
setWatchlistInputValue,
setWatchlistFeedback
} = setters;
const parseWatchlistInput = useCallback((value) => {
if (typeof value !== 'string') {
return [];
}
return Array.from(
new Set(
value
.split(/[\s,]+/)
.map((symbol) => symbol.trim().toUpperCase())
.filter(Boolean)
)
);
}, []);
const commitWatchlistInput = useCallback((value) => {
const parsed = parseWatchlistInput(value);
if (parsed.length === 0) {
return [];
}
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
setWatchlistInputValue('');
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
return parsed;
}, [parseWatchlistInput, watchlistFeedback, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, setters]);
const handleWatchlistRemove = useCallback((symbolToRemove) => {
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback, setWatchlistDraftSymbols, setWatchlistFeedback]);
const handleWatchlistInputChange = useCallback((value) => {
setWatchlistInputValue(value);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback, setWatchlistInputValue, setWatchlistFeedback]);
const handleWatchlistInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitWatchlistInput(watchlistInputValue);
}
}, [commitWatchlistInput, watchlistInputValue]);
const handleWatchlistSuggestionClick = useCallback((symbol) => {
if (watchlistDraftSymbols.includes(symbol)) {
return;
}
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistDraftSymbols, watchlistFeedback, setWatchlistDraftSymbols, setWatchlistFeedback]);
const handleWatchlistRestoreCurrent = useCallback(() => {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
const handleWatchlistSave = useCallback(() => {
const pendingTickers = parseWatchlistInput(watchlistInputValue);
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
if (nextTickers.length === 0) {
setWatchlistFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' });
return;
}
if (!clientRef.current) {
setWatchlistFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setters.setIsWatchlistSaving(true);
setWatchlistFeedback(null);
setWatchlistDraftSymbols(nextTickers);
setWatchlistInputValue('');
const success = clientRef.current.send({
type: 'update_watchlist',
tickers: nextTickers
});
if (!success) {
setters.setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue, clientRef, setters.setIsWatchlistSaving, setWatchlistFeedback, setWatchlistDraftSymbols, setWatchlistInputValue]);
const watchlistSuggestions = useMemo(
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
[]
);
const isWatchlistDraftDirty = useMemo(() => {
if (watchlistInputValue.trim()) {
return true;
}
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
return true;
}
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
return {
parseWatchlistInput,
commitWatchlistInput,
handleWatchlistRemove,
handleWatchlistInputChange,
handleWatchlistInputKeyDown,
handleWatchlistSuggestionClick,
handleWatchlistRestoreCurrent,
handleWatchlistSave,
watchlistSuggestions,
isWatchlistDraftDirty
};
}

File diff suppressed because it is too large Load Diff