Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { MarketPanel } from './market/MarketPanel';
import { SessionPanel } from './SessionPanel';
import { SettingsPanel } from './settings/SettingsPanel';
import { ProviderPanel } from './ProviderPanel';
import { SecuritySnapshots } from './securities/security-snapshots';
import { SecurityWorkspace } from './securities/SecurityWorkspace';
import { AssetTabs } from './AssetTabs';
import { Navigation, type WorkbenchView } from './ui/Navigation';
import { StatusIndicator, StateNotice } from './ui/Primitives';
import { StatusIndicator } from './ui/Primitives';
const AnalyticsPanel = lazy(() => import('./analytics/AnalyticsPanel').then(module => ({ default: module.AnalyticsPanel })));
const PortfolioPanel = lazy(() => import('./portfolio/PortfolioPanel').then(module => ({ default: module.PortfolioPanel })));

Expand All @@ -28,6 +29,7 @@ const ThesisPanel = lazy(() => import('./theses/ThesisPanel').then(m => ({ defau

export function App() {
const [state, setState] = useState<BackendState>({ phase: 'idle', detail: '等待本地服务启动。' });
const [snapshots] = useState(() => new SecuritySnapshots());
const [busy, setBusy] = useState(false);
const [assetRevision, setAssetRevision] = useState(0);
const [researchSymbol, setResearchSymbol] = useState<string>();
Expand Down Expand Up @@ -60,6 +62,7 @@ export function App() {
} catch { setState({ phase: 'failed', detail: '桌面通信请求失败。请关闭应用并重新运行 start-dev.cmd。' }); }
finally { setBusy(false); }
};
useEffect(() => { if (state.phase !== 'healthy') snapshots.clear(); }, [state.phase, snapshots]);
const pending = busy || state.phase === 'starting' || state.phase === 'stopping';
const navigate = (next: WorkbenchView) => { ++navigation.current; if (next === 'securities' || next === 'market') clearReportContext(); setView(next); };
return (
Expand All @@ -80,18 +83,17 @@ export function App() {
<button className="secondary" onClick={action} disabled={pending}>{pending ? '正在处理…' : state.phase === 'healthy' ? '重新检查' : '重试启动'}</button>
</section>
</header>
<div className="workbench">
<div className={`workbench${view === 'sessions' ? ' sessions-workbench' : ''}`}>
<aside className="workbench-navigation" aria-label="工作台导航">
<Navigation view={view} onSelect={navigate} />
<p className="navigation-caption">每次研究,都有据可查。</p>
</aside>
<main className="workbench-content">
<AssetTabs available={state.phase === 'healthy'} view={view} onSelectionStart={() => { clearReportContext(); return ++navigation.current; }} onOpen={ticket => { if (ticket !== navigation.current) return; setAssetRevision(n => n + 1); navigate('securities'); }} />
{view === 'outcomes' ? <Suspense fallback={<p role="status">正在打开投资结果…</p>}><OutcomePanel available={state.phase === 'healthy'} /></Suspense> : view === 'evaluation' ? <Suspense fallback={<p role="status">正在打开评测中心…</p>}><EvaluationPanel available={state.phase === 'healthy'} /></Suspense> : view === 'today' ? <Suspense fallback={<p role="status">正在打开Today…</p>}><TodayPanel available={state.phase === 'healthy'} /></Suspense> : view === 'calendar' ? <Suspense fallback={<p role="status">正在打开事件日历…</p>}><CalendarPanel available={state.phase === 'healthy'} onResearch={target=>{setResearchEventRef(target.event_ref);setDiscoveryTarget({symbols:[target.symbol],mode:target.mode,provider:target.provider});setResearchSymbol(target.symbol);clearReportContext();setResearchEntry(n => n + 1);navigate('research');}} /></Suspense> : view === 'discover' ? <Suspense fallback={<p role="status">正在打开机会发现…</p>}><DiscoverPanel available={state.phase === 'healthy'} onCompare={target => {setDiscoveryTarget(target);setView('analytics');}} onResearch={target => {setResearchEventRef(undefined);setDiscoveryTarget(target);setResearchSymbol(target.symbols[0]);clearReportContext();setResearchEntry(n => n + 1);navigate('research');}} /></Suspense> : view === 'theses' ? <Suspense fallback={<p role="status">正在打开投资论点…</p>}><ThesisPanel available={state.phase === 'healthy'} /></Suspense> : view === 'research' ? <Suspense fallback={<p role="status">正在打开研究采集…</p>}><ResearchPanel available={state.phase === 'healthy'} initialSymbol={researchSymbol} initialMode={discoveryTarget?.mode} initialProvider={discoveryTarget?.provider} initialEventRef={researchEventRef} entryKey={researchEntry} onTaskChange={clearReportContext} onAssociateReport={setReportContext} /></Suspense> : view === 'skills' ? <Suspense fallback={<p role="status">正在打开技能目录…</p>}><SkillsPanel available={state.phase === 'healthy'} /></Suspense> : view === 'analytics' ? <Suspense fallback={<p role="status">正在打开风险与对比…</p>}><AnalyticsPanel available={state.phase === 'healthy'} initialSymbols={discoveryTarget?.symbols.join(' ')} initialMode={discoveryTarget?.mode} initialProvider={discoveryTarget?.provider} /></Suspense> : view === 'portfolios' ? <Suspense fallback={<p role="status">正在打开组合工作台…</p>}><PortfolioPanel available={state.phase === 'healthy'} /></Suspense> : view === 'securities' ? <SecurityWorkspace key={assetRevision} available={state.phase === 'healthy'} onResearch={symbol => { setResearchEventRef(undefined);setDiscoveryTarget(undefined);setResearchSymbol(symbol); clearReportContext();setResearchEntry(n => n + 1);navigate('research'); }} /> : view === 'market' ? <MarketPanel available={state.phase === 'healthy'} onOpenSecurity={async symbol => { if (!['AAPL.US', 'MSFT.US', 'NVDA.US', 'TSLA.US'].includes(symbol)) throw new Error('不支持的总览详情'); const ticket = ++navigation.current; clearReportContext(); await window.researchTrail!.selectSecurity(symbol); if (ticket !== navigation.current) return; setAssetRevision(n => n + 1); navigate('securities'); }} /> : view === 'sessions' ? <SessionPanel available={state.phase === 'healthy'} reportContext={reportContext} onClearContext={clearReportContext} /> : view === 'providers' ? <ProviderPanel available={state.phase === 'healthy'} /> : <SettingsPanel available={state.phase === 'healthy'} />}
{view === 'outcomes' ? <Suspense fallback={<p role="status">正在打开投资结果…</p>}><OutcomePanel available={state.phase === 'healthy'} /></Suspense> : view === 'evaluation' ? <Suspense fallback={<p role="status">正在打开评测中心…</p>}><EvaluationPanel available={state.phase === 'healthy'} /></Suspense> : view === 'today' ? <Suspense fallback={<p role="status">正在打开Today…</p>}><TodayPanel available={state.phase === 'healthy'} /></Suspense> : view === 'calendar' ? <Suspense fallback={<p role="status">正在打开事件日历…</p>}><CalendarPanel available={state.phase === 'healthy'} onResearch={target=>{setResearchEventRef(target.event_ref);setDiscoveryTarget({symbols:[target.symbol],mode:target.mode,provider:target.provider});setResearchSymbol(target.symbol);clearReportContext();setResearchEntry(n => n + 1);navigate('research');}} /></Suspense> : view === 'discover' ? <Suspense fallback={<p role="status">正在打开机会发现…</p>}><DiscoverPanel available={state.phase === 'healthy'} onCompare={target => {setDiscoveryTarget(target);setView('analytics');}} onResearch={target => {setResearchEventRef(undefined);setDiscoveryTarget(target);setResearchSymbol(target.symbols[0]);clearReportContext();setResearchEntry(n => n + 1);navigate('research');}} /></Suspense> : view === 'theses' ? <Suspense fallback={<p role="status">正在打开投资论点…</p>}><ThesisPanel available={state.phase === 'healthy'} /></Suspense> : view === 'research' ? <Suspense fallback={<p role="status">正在打开研究采集…</p>}><ResearchPanel available={state.phase === 'healthy'} initialSymbol={researchSymbol} initialMode={discoveryTarget?.mode} initialProvider={discoveryTarget?.provider} initialEventRef={researchEventRef} entryKey={researchEntry} onTaskChange={clearReportContext} onAssociateReport={setReportContext} /></Suspense> : view === 'skills' ? <Suspense fallback={<p role="status">正在打开技能目录…</p>}><SkillsPanel available={state.phase === 'healthy'} /></Suspense> : view === 'analytics' ? <Suspense fallback={<p role="status">正在打开风险与对比…</p>}><AnalyticsPanel available={state.phase === 'healthy'} initialSymbols={discoveryTarget?.symbols.join(' ')} initialMode={discoveryTarget?.mode} initialProvider={discoveryTarget?.provider} /></Suspense> : view === 'portfolios' ? <Suspense fallback={<p role="status">正在打开组合工作台…</p>}><PortfolioPanel available={state.phase === 'healthy'} /></Suspense> : view === 'securities' ? <SecurityWorkspace snapshots={snapshots} key={assetRevision} available={state.phase === 'healthy'} onResearch={symbol => { setResearchEventRef(undefined);setDiscoveryTarget(undefined);setResearchSymbol(symbol); clearReportContext();setResearchEntry(n => n + 1);navigate('research'); }} /> : view === 'market' ? <MarketPanel available={state.phase === 'healthy'} onOpenSecurity={async symbol => { if (!['AAPL.US', 'MSFT.US', 'NVDA.US', 'TSLA.US'].includes(symbol)) throw new Error('不支持的总览详情'); const ticket = ++navigation.current; clearReportContext(); await window.researchTrail!.selectSecurity(symbol); if (ticket !== navigation.current) return; setAssetRevision(n => n + 1); navigate('securities'); }} /> : view === 'sessions' ? null : view === 'providers' ? <ProviderPanel onConfigurationChange={provider => snapshots.invalidate(provider)} available={state.phase === 'healthy'} /> : <SettingsPanel available={state.phase === 'healthy'} />}
</main>
<aside className="workbench-copilot" aria-label="常驻研究助手">
<div className="copilot-heading"><span className="assistant-mark" aria-hidden="true">R</span><div><h2>AI 研究助手</h2><p>会话、工具与证据</p></div></div>
{view === 'sessions' ? <StateNotice kind="info">正在主工作区查看完整会话。切换页面后可在此继续研究。</StateNotice> : <SessionPanel available={state.phase === 'healthy'} compact reportContext={reportContext} onClearContext={clearReportContext} />}
<aside className={view === 'sessions' ? 'workbench-conversation full-session-host' : 'workbench-copilot'} aria-label={view === 'sessions' ? '完整研究会话' : '常驻研究助手'}>
<SessionPanel available={state.phase === 'healthy'} compact={view !== 'sessions'} reportContext={reportContext} onClearContext={clearReportContext} />
</aside>
</div>
<footer>每次研究,都有据可查。</footer>
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/AssetTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ export function AssetTabs({ available, view, onOpen, onSelectionStart }: { avail
finally { setBusy(false); }
}
return <section className="asset-strip" aria-label="常驻资产页签">
<span className="asset-strip-label">自选资产</span>
<div className="asset-strip-label"><span>自选资产</span><small>{state ? `${state.entries.length} 个证券` : '读取中'}</small></div>
<nav aria-label="自选资产">{state?.entries.map(entry => <button key={entry.symbol} disabled={busy || !available}
aria-pressed={state.active_symbol === entry.symbol} onClick={() => void open(entry.symbol)}>{entry.symbol}<small>{entry.name}</small></button>)}</nav>
{!state?.entries.length && <p>尚无自选资产;请在证券工作台添加。</p>}
{!state?.entries.length && <p>{available && !state ? '正在读取自选资产…' : available ? '尚无自选资产;请在证券工作台添加。' : '连接就绪后显示自选资产。'}</p>}
{error && <StateNotice kind="warning">{error}</StateNotice>}
</section>;
}
8 changes: 4 additions & 4 deletions apps/desktop/src/renderer/ProviderPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import type { CapabilityView, ProviderConfiguration, ProviderCredentials, Provid
const names = { longbridge: 'Longbridge 行情', 'longbridge-account': 'Longbridge 只读账户', massive: 'Massive 美股' };
const states = { unverified: '未验证', simulated: '模拟通过', real: '真实查询通过', restricted: '权限受限', failed: '失败' };

function ProviderEditor({ profile, refresh }: { profile: ProviderProfile; refresh: () => Promise<void> }) {
function ProviderEditor({ profile, refresh, onChanged }: { profile: ProviderProfile; onChanged?: () => void; refresh: () => Promise<void> }) {
const [config, setConfig] = useState<ProviderConfiguration>({ enabled: profile.enabled, region: profile.region,
timeout_seconds: profile.timeout_seconds, cache_ttl_seconds: profile.cache_ttl_seconds, timeliness: profile.timeliness, cli_path: profile.cli_path });
const [secrets, setSecrets] = useState<ProviderCredentials>({});
const [busy, setBusy] = useState(false), [notice, setNotice] = useState('');
async function action(work: () => Promise<unknown>) {
setBusy(true); setNotice('');
try { await work(); await refresh(); setNotice('已保存当前操作。'); }
try { await work(); onChanged?.(); await refresh(); setNotice('已保存当前操作。'); }
catch { setNotice('操作失败,请检查配置或系统凭证存储。'); }
finally { setSecrets({}); setBusy(false); }
}
Expand Down Expand Up @@ -40,7 +40,7 @@ function ProviderEditor({ profile, refresh }: { profile: ProviderProfile; refres
</section>;
}

export function ProviderPanel({ available }: { available: boolean }) {
export function ProviderPanel({ available, onConfigurationChange }: { available: boolean; onConfigurationChange?: (provider: ProviderId) => void }) {
const [provider, setProvider] = useState<ProviderId>('longbridge');
const [profiles, setProfiles] = useState<ProviderProfile[]>([]), [caps, setCaps] = useState<CapabilityView[]>([]);
const [capability, setCapability] = useState<ReadQuery['capability']>('market.quote');
Expand Down Expand Up @@ -74,7 +74,7 @@ export function ProviderPanel({ available }: { available: boolean }) {
<h2>数据与只读账户</h2>
<p>默认使用自主编写的模拟数据。真实模式失败会直接显示错误;此入口不提供下单功能。现有会话行情工具仍使用模拟数据。</p>
<label>数据提供商<select aria-label="数据提供商" disabled={!available || busy} value={provider} onChange={e => { clear(); const p = e.target.value as ProviderId; setProvider(p); setCapability(p === 'longbridge-account' ? 'account.positions' : 'market.quote'); }}>{Object.entries(names).map(([p, name]) => <option key={p} value={p}>{name}</option>)}</select></label>
{selected && <ProviderEditor key={`${provider}-${selected.revision}-${selected.configured}`} profile={selected} refresh={async () => { clear(); await refresh(); }} />}
{selected && <ProviderEditor key={`${provider}-${selected.revision}-${selected.configured}`} profile={selected} onChanged={() => onConfigurationChange?.(provider)} refresh={async () => { clear(); await refresh(); }} />}
<section className="connection">
<h3>只读查询验收</h3>
<label>查询模式<select aria-label="查询模式" disabled={busy} value={mode} onChange={e => { clear(); setMode(e.target.value as 'simulated' | 'real'); }}><option value="simulated">模拟响应(不请求服务商)</option><option value="real">真实查询(需要本机凭证与权限)</option></select></label>
Expand Down
Loading
Loading