diff --git a/web_dashboard/frontend/index.html b/web_dashboard/frontend/index.html new file mode 100644 index 00000000..87db1b11 --- /dev/null +++ b/web_dashboard/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + TradingAgents Dashboard + + + + + +
+ + + diff --git a/web_dashboard/frontend/src/App.jsx b/web_dashboard/frontend/src/App.jsx new file mode 100644 index 00000000..997a0280 --- /dev/null +++ b/web_dashboard/frontend/src/App.jsx @@ -0,0 +1,165 @@ +import { useState, useEffect, lazy, Suspense } from 'react' +import { Routes, Route, NavLink, useLocation, useNavigate } from 'react-router-dom' +import { + FundOutlined, + MonitorOutlined, + FileTextOutlined, + ClusterOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, +} from '@ant-design/icons' + +const ScreeningPanel = lazy(() => import('./pages/ScreeningPanel')) +const AnalysisMonitor = lazy(() => import('./pages/AnalysisMonitor')) +const ReportsViewer = lazy(() => import('./pages/ReportsViewer')) +const BatchManager = lazy(() => import('./pages/BatchManager')) + +const navItems = [ + { path: '/', icon: , label: '筛选', key: '1' }, + { path: '/monitor', icon: , label: '监控', key: '2' }, + { path: '/reports', icon: , label: '报告', key: '3' }, + { path: '/batch', icon: , label: '批量', key: '4' }, +] + +function Layout({ children }) { + const [collapsed, setCollapsed] = useState(false) + const [isMobile, setIsMobile] = useState(false) + const location = useLocation() + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768) + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + const currentPage = navItems.find(item => + item.path === '/' + ? location.pathname === '/' + : location.pathname.startsWith(item.path) + )?.label || 'TradingAgents' + + return ( +
+ {/* Sidebar - Apple Glass Navigation */} + {!isMobile && ( + + )} + + {/* Main Content */} +
+ {!isMobile && ( +
+
{currentPage}
+
+ {new Date().toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} +
+
+ )} + +
+ {children} +
+
+ + {/* Mobile TabBar */} + {isMobile && ( + + )} +
+ ) +} + +export default function App() { + const navigate = useNavigate() + + useEffect(() => { + const handleKeyDown = (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return + // Close modals on Escape + if (e.key === 'Escape') { + document.querySelector('.ant-modal-wrap')?.click() + return + } + // Navigation shortcuts + switch (e.key) { + case '1': navigate('/'); break + case '2': navigate('/monitor'); break + case '3': navigate('/reports'); break + case '4': navigate('/batch'); break + default: break + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [navigate]) + + return ( + + +
加载中...
+ + }> + + } /> + } /> + } /> + } /> + +
+
+ ) +} diff --git a/web_dashboard/frontend/src/index.css b/web_dashboard/frontend/src/index.css new file mode 100644 index 00000000..9c121dc9 --- /dev/null +++ b/web_dashboard/frontend/src/index.css @@ -0,0 +1,879 @@ +/* TradingAgents Dashboard - Apple Design System */ + +:root { + /* === Apple Color System === */ + /* Backgrounds */ + --color-black: #000000; + --color-white: #ffffff; + --color-light-gray: #f5f5f7; + --color-near-black: #1d1d1f; + + /* Interactive */ + --color-apple-blue: #0071e3; + --color-link-blue: #0066cc; + --color-link-blue-bright: #2997ff; + + /* Text */ + --color-text-dark: rgba(0, 0, 0, 0.8); + --color-text-secondary: rgba(0, 0, 0, 0.48); + --color-text-white-80: rgba(255, 255, 255, 0.8); + --color-text-white-48: rgba(255, 255, 255, 0.48); + + /* Dark Surfaces */ + --color-dark-1: #272729; + --color-dark-2: #262628; + --color-dark-3: #28282a; + --color-dark-4: #2a2a2d; + --color-dark-5: #242426; + + /* Buttons */ + --color-btn-active: #ededf2; + --color-btn-light: #fafafc; + --color-overlay: rgba(210, 210, 215, 0.64); + --color-white-32: rgba(255, 255, 255, 0.32); + + /* Shadows */ + --shadow-card: rgba(0, 0, 0, 0.22) 3px 5px 30px 0px; + + /* === Semantic Colors (kept for financial data) === */ + --color-buy: #22c55e; + --color-sell: #dc2626; + --color-hold: #f59e0b; + --color-running: #a855f7; + + /* === Spacing (Apple 8px base) === */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 28px; + --space-8: 32px; + --space-9: 36px; + --space-10: 40px; + --space-11: 44px; + --space-12: 48px; + --space-14: 56px; + --space-16: 64px; + + /* === Typography === */ + --font-display: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-text: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-data: 'DM Sans', 'JetBrains Mono', 'Menlo', monospace; + + /* Apple type scale */ + --text-hero: 56px; + --text-section: 40px; + --text-tile: 28px; + --text-card: 21px; + --text-nav: 17px; + --text-body: 17px; + --text-button: 17px; + --text-link: 14px; + --text-caption: 12px; + + /* === Border Radius === */ + --radius-micro: 5px; + --radius-standard: 8px; + --radius-comfortable: 11px; + --radius-large: 12px; + --radius-pill: 980px; + --radius-circle: 50%; + + /* === Transitions === */ + --transition-fast: 150ms ease; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-text); + background-color: var(--color-light-gray); + color: var(--color-near-black); + line-height: 1.47; + letter-spacing: -0.374px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* === Scrollbar === */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: var(--radius-standard); +} +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.25); +} + +/* === Layout === */ +.dashboard-layout { + display: flex; + min-height: 100vh; +} + +/* === Sidebar (Apple Glass Nav) === */ +.sidebar { + width: 240px; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border-right: none; + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 100; + transition: width var(--transition-fast); +} + +.sidebar.collapsed { + width: 64px; +} + +.sidebar-logo { + padding: var(--space-4) var(--space-4); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + font-weight: 600; + font-size: 14px; + color: var(--color-white); + display: flex; + align-items: center; + gap: var(--space-2); + height: 48px; + letter-spacing: -0.28px; +} + +.sidebar-nav { + flex: 1; + padding: var(--space-2) var(--space-2); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-standard); + color: var(--color-text-white-80); + text-decoration: none; + transition: all var(--transition-fast); + cursor: pointer; + margin-bottom: var(--space-1); + font-size: 14px; + font-weight: 400; + height: 36px; +} + +.nav-item:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--color-white); +} + +.nav-item.active { + background: rgba(255, 255, 255, 0.12); + color: var(--color-white); +} + +.nav-item svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.nav-item span { + white-space: nowrap; + overflow: hidden; +} + +.sidebar-collapse-btn { + background: none; + border: none; + color: var(--color-text-white-48); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 12px; + padding: var(--space-3) var(--space-3); + border-radius: var(--radius-standard); + transition: color var(--transition-fast); + width: 100%; + justify-content: flex-start; +} + +.sidebar-collapse-btn:hover { + color: var(--color-white); +} + +.sidebar-collapse-btn:focus-visible { + outline: 2px solid var(--color-apple-blue); + outline-offset: 2px; +} + +/* Collapsed sidebar: hide button label, center icon */ +.sidebar.collapsed .sidebar-collapse-btn { + justify-content: center; + padding: var(--space-3); +} +.sidebar.collapsed .sidebar-collapse-btn span { + display: none; +} + +/* === Main Content === */ +.main-content { + flex: 1; + margin-left: 240px; + display: flex; + flex-direction: column; + min-height: 100vh; + transition: margin-left var(--transition-fast); +} + +.sidebar.collapsed ~ .main-content { + margin-left: 64px; +} + +.topbar { + height: 48px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); + background: var(--color-white); + position: sticky; + top: 0; + z-index: 50; +} + +.topbar-title { + font-size: 14px; + font-weight: 600; + color: var(--color-near-black); + letter-spacing: -0.224px; +} + +.topbar-date { + font-size: 14px; + color: var(--color-text-secondary); + font-weight: 400; +} + +.page-content { + flex: 1; + padding: var(--space-8) var(--space-6); + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* === Apple Cards === */ +.card { + background: var(--color-white); + border: none; + border-radius: var(--radius-standard); + padding: var(--space-6); + box-shadow: none; + transition: box-shadow var(--transition-fast); +} + +.card:hover { + box-shadow: var(--shadow-card); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.card-title { + font-family: var(--font-display); + font-size: 21px; + font-weight: 700; + letter-spacing: 0.231px; + line-height: 1.19; + color: var(--color-near-black); +} + +/* === Apple Section === */ +.section-dark { + background: var(--color-black); + color: var(--color-white); +} + +.section-light { + background: var(--color-light-gray); + color: var(--color-near-black); +} + +.section-full { + min-height: 100vh; +} + +/* === Typography === */ +.text-hero { + font-family: var(--font-display); + font-size: var(--text-hero); + font-weight: 600; + line-height: 1.07; + letter-spacing: -0.28px; +} + +.text-section-heading { + font-family: var(--font-display); + font-size: var(--text-section); + font-weight: 600; + line-height: 1.10; +} + +.text-tile-heading { + font-family: var(--font-display); + font-size: var(--text-tile); + font-weight: 400; + line-height: 1.14; + letter-spacing: 0.196px; +} + +.text-card-title { + font-family: var(--font-display); + font-size: var(--text-card); + font-weight: 700; + line-height: 1.19; + letter-spacing: 0.231px; +} + +.text-body { + font-family: var(--font-text); + font-size: var(--text-body); + font-weight: 400; + line-height: 1.47; + letter-spacing: -0.374px; +} + +.text-emphasis { + font-family: var(--font-text); + font-size: var(--text-body); + font-weight: 600; + line-height: 1.24; + letter-spacing: -0.374px; +} + +.text-link { + font-family: var(--font-text); + font-size: var(--text-link); + font-weight: 400; + line-height: 1.43; + letter-spacing: -0.224px; +} + +.text-caption { + font-family: var(--font-text); + font-size: var(--text-caption); + font-weight: 400; + line-height: 1.29; + letter-spacing: -0.224px; + color: var(--color-text-secondary); +} + +.text-data { + font-family: var(--font-data); + font-size: 14px; +} + +/* === Apple Buttons === */ +.btn-primary { + background: var(--color-apple-blue); + color: var(--color-white); + border: none; + border-radius: var(--radius-standard); + padding: 8px 15px; + font-family: var(--font-text); + font-size: var(--text-button); + font-weight: 400; + line-height: 1; + cursor: pointer; + transition: background var(--transition-fast); + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.btn-primary:hover { + background: #0077ED; +} + +.btn-primary:active { + background: var(--color-btn-active); +} + +.btn-primary:focus-visible { + outline: 2px solid var(--color-apple-blue); + outline-offset: 2px; +} + +.btn-secondary { + background: var(--color-near-black); + color: var(--color-white); + border: none; + border-radius: var(--radius-standard); + padding: 8px 15px; + font-family: var(--font-text); + font-size: var(--text-button); + font-weight: 400; + line-height: 1; + cursor: pointer; + transition: opacity var(--transition-fast); +} + +.btn-secondary:hover { + opacity: 0.85; +} + +.btn-secondary:active { + background: var(--color-dark-1); +} + +.btn-secondary:focus-visible { + outline: 2px solid var(--color-apple-blue); + outline-offset: 2px; +} + +.btn-ghost { + background: transparent; + color: var(--color-link-blue); + border: 1px solid var(--color-link-blue); + border-radius: var(--radius-pill); + padding: 6px 14px; + font-family: var(--font-text); + font-size: var(--text-link); + font-weight: 400; + cursor: pointer; + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.btn-ghost:hover { + text-decoration: underline; +} + +.btn-ghost:focus-visible { + outline: 2px solid var(--color-apple-blue); + outline-offset: 2px; +} + +.btn-filter { + background: var(--color-btn-light); + color: var(--color-text-dark); + border: none; + border-radius: var(--radius-comfortable); + padding: 0px 14px; + height: 32px; + font-family: var(--font-text); + font-size: 12px; + font-weight: 400; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: var(--space-1); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04); + transition: all var(--transition-fast); +} + +.btn-filter:hover { + box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.08); +} + +.btn-filter:active { + background: var(--color-btn-active); +} + +.btn-filter:focus-visible { + outline: 2px solid var(--color-apple-blue); + outline-offset: 2px; +} + +/* === Decision Badges === */ +.badge-buy { + background: var(--color-buy); + color: var(--color-white); + padding: 4px 12px; + border-radius: var(--radius-pill); + font-family: var(--font-text); + font-size: 14px; + font-weight: 600; +} + +.badge-sell { + background: var(--color-sell); + color: var(--color-white); + padding: 4px 12px; + border-radius: var(--radius-pill); + font-family: var(--font-text); + font-size: 14px; + font-weight: 600; +} + +.badge-hold { + background: var(--color-hold); + color: var(--color-white); + padding: 4px 12px; + border-radius: var(--radius-pill); + font-family: var(--font-text); + font-size: 14px; + font-weight: 600; +} + +.badge-running { + background: var(--color-running); + color: var(--color-white); + padding: 4px 12px; + border-radius: var(--radius-pill); + font-family: var(--font-text); + font-size: 14px; + font-weight: 600; +} + +/* === Stage Pills === */ +.stage-pill { + padding: 8px 16px; + border-radius: var(--radius-standard); + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 14px; + font-weight: 500; + transition: all var(--transition-fast); +} + +.stage-pill.completed { + background: rgba(34, 197, 94, 0.15); + color: var(--color-buy); +} + +.stage-pill.running { + background: rgba(168, 85, 247, 0.15); + color: var(--color-running); +} + +.stage-pill.pending { + background: rgba(0, 0, 0, 0.05); + color: var(--color-text-secondary); +} + +.stage-pill.failed { + background: rgba(220, 38, 38, 0.15); + color: var(--color-sell); +} + +/* === Empty States === */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-16); + text-align: center; +} + +.empty-state svg { + width: 48px; + height: 48px; + color: var(--color-text-secondary); + margin-bottom: var(--space-4); +} + +.empty-state-title { + font-family: var(--font-display); + font-size: 21px; + font-weight: 700; + letter-spacing: 0.231px; + color: var(--color-near-black); + margin-bottom: var(--space-2); +} + +.empty-state-description { + font-size: 14px; + color: var(--color-text-secondary); + max-width: 280px; +} + +/* === Progress Bar === */ +.progress-bar { + height: 4px; + background: rgba(0, 0, 0, 0.08); + border-radius: 2px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: var(--color-apple-blue); + border-radius: 2px; + transition: width 300ms ease-out; +} + +/* === Status Dot === */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-dot.connected { + background: var(--color-buy); +} + +.status-dot.error { + background: var(--color-sell); +} + +/* === Data Table === */ +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th { + font-family: var(--font-text); + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); + text-align: left; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + letter-spacing: 0.024px; +} + +.data-table td { + padding: var(--space-4); + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + font-size: 14px; + color: var(--color-near-black); +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tr:hover td { + background: rgba(0, 0, 0, 0.02); +} + +.data-table .numeric { + font-family: var(--font-data); + text-align: right; +} + +/* === Loading Pulse === */ +@keyframes apple-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.loading-pulse { + animation: apple-pulse 2s ease-in-out infinite; + color: var(--color-apple-blue); +} + +/* === Responsive === */ +@media (max-width: 1024px) { + .sidebar { + width: 64px; + } + .sidebar-logo span, + .nav-item span, + .sidebar-collapse-btn span:not(:first-child) { + display: none; + } + .main-content { + margin-left: 64px; + } +} + +@media (max-width: 767px) { + .sidebar { + display: none; + } + .main-content { + margin-left: 0; + } + .topbar { + padding: 0 var(--space-4); + } + .page-content { + padding: var(--space-4); + } +} + +/* === Ant Design Overrides === */ +.ant-table { + background: transparent !important; + font-family: var(--font-text) !important; +} + +.ant-table-thead > tr > th { + background: transparent !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important; + color: var(--color-text-secondary) !important; + font-size: 12px !important; + font-weight: 600 !important; + letter-spacing: 0.024px !important; + padding: var(--space-3) var(--space-4) !important; +} + +.ant-table-tbody > tr > td { + border-bottom: 1px solid rgba(0, 0, 0, 0.06) !important; + padding: var(--space-4) !important; + color: var(--color-near-black) !important; + font-size: 14px !important; +} + +.ant-table-tbody > tr:hover > td { + background: rgba(0, 0, 0, 0.02) !important; +} + +.ant-select-selector { + border-radius: var(--radius-comfortable) !important; + background: var(--color-btn-light) !important; + border: none !important; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important; + font-family: var(--font-text) !important; +} + +.ant-select-dropdown { + border-radius: var(--radius-standard) !important; + box-shadow: var(--shadow-card) !important; +} + +.ant-popover-inner { + border-radius: var(--radius-standard) !important; + box-shadow: var(--shadow-card) !important; +} + +.ant-popover-title { + font-family: var(--font-display) !important; + font-weight: 600 !important; + border-bottom: none !important; +} + +.ant-btn-primary { + background: var(--color-apple-blue) !important; + border: none !important; + border-radius: var(--radius-standard) !important; + font-family: var(--font-text) !important; + font-size: 14px !important; + font-weight: 400 !important; + box-shadow: none !important; +} + +.ant-btn-primary:hover { + background: #0077ED !important; +} + +.ant-btn-primary:active { + background: var(--color-btn-active) !important; +} + +.ant-btn-primary:focus-visible { + outline: 2px solid var(--color-apple-blue) !important; + outline-offset: 2px !important; +} + +.ant-btn-default { + border-radius: var(--radius-standard) !important; + border: none !important; + background: var(--color-btn-light) !important; + color: var(--color-text-dark) !important; + font-family: var(--font-text) !important; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important; +} + +.ant-btn-default:hover { + background: var(--color-btn-active) !important; +} + +.ant-skeleton { + padding: var(--space-4) !important; +} + +.ant-result-title { + font-family: var(--font-display) !important; + font-weight: 600 !important; +} + +.ant-statistic-title { + font-family: var(--font-text) !important; + font-size: 12px !important; + color: var(--color-text-secondary) !important; + letter-spacing: 0.024px !important; +} + +.ant-statistic-content { + font-family: var(--font-data) !important; + font-size: 28px !important; + font-weight: 600 !important; + color: var(--color-near-black) !important; +} + +.ant-progress-inner { + background: rgba(0, 0, 0, 0.08) !important; + border-radius: 2px !important; +} + +.ant-progress-bg { + background: var(--color-apple-blue) !important; +} + +.ant-tag { + border-radius: var(--radius-pill) !important; + font-family: var(--font-text) !important; + font-size: 12px !important; + font-weight: 600 !important; + border: none !important; +} + +.ant-input-number { + border-radius: var(--radius-comfortable) !important; + border: none !important; + background: var(--color-btn-light) !important; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important; + font-family: var(--font-text) !important; +} + +.ant-input-number-input { + font-family: var(--font-text) !important; +} + +.ant-tabs-nav::before { + border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important; +} + +.ant-tabs-tab { + font-family: var(--font-text) !important; + font-size: 14px !important; + color: var(--color-text-secondary) !important; +} + +.ant-tabs-tab-active .ant-tabs-tab-btn { + color: var(--color-near-black) !important; + font-weight: 600 !important; +} diff --git a/web_dashboard/frontend/src/main.jsx b/web_dashboard/frontend/src/main.jsx new file mode 100644 index 00000000..83c27d64 --- /dev/null +++ b/web_dashboard/frontend/src/main.jsx @@ -0,0 +1,48 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { ConfigProvider, theme } from 'antd' +import App from './App' +import './index.css' + +// Apple Design System Ant Design configuration +const appleTheme = { + algorithm: theme.defaultAlgorithm, + token: { + colorPrimary: '#0071e3', + colorSuccess: '#22c55e', + colorError: '#dc2626', + colorWarning: '#f59e0b', + colorInfo: '#0071e3', + colorBgBase: '#ffffff', + colorBgContainer: '#ffffff', + colorBgElevated: '#f5f5f7', + colorBorder: 'rgba(0, 0, 0, 0.08)', + colorText: '#1d1d1f', + colorTextSecondary: 'rgba(0, 0, 0, 0.48)', + borderRadius: 8, + fontFamily: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif', + wireframe: false, + }, + components: { + Button: { + borderRadius: 8, + }, + Select: { + borderRadius: 11, + }, + Table: { + borderRadius: 8, + }, + }, +} + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + +) diff --git a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx index 5f1db984..d13c6775 100644 --- a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx +++ b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx @@ -1,14 +1,14 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' -import { Card, Progress, Timeline, Badge, Empty, Button, Tag, Result, message } from 'antd' +import { Card, Progress, Badge, Empty, Button, Result, message } from 'antd' import { CheckCircleOutlined, SyncOutlined, CloseCircleOutlined } from '@ant-design/icons' const ANALYSIS_STAGES = [ - { key: 'analysts', label: '分析师团队', description: 'Market / Social / News / Fundamentals' }, - { key: 'research', label: '研究员辩论', description: 'Bull vs Bear Researcher debate' }, - { key: 'trader', label: '交易员', description: 'Compose investment plan' }, - { key: 'risk', label: '风险管理', description: 'Aggressive vs Conservative vs Neutral' }, - { key: 'portfolio', label: '组合经理', description: 'Final BUY/HOLD/SELL decision' }, + { key: 'analysts', label: '分析师团队' }, + { key: 'research', label: '研究员辩论' }, + { key: 'trader', label: '交易员' }, + { key: 'risk', label: '风险管理' }, + { key: 'portfolio', label: '组合经理' }, ] export default function AnalysisMonitor() { @@ -21,6 +21,7 @@ export default function AnalysisMonitor() { const wsRef = useRef(null) const fetchInitialState = useCallback(async () => { + if (!taskId) return setLoading(true) try { const res = await fetch(`/api/analysis/status/${taskId}`) @@ -53,7 +54,7 @@ export default function AnalysisMonitor() { setTask(taskData) } } catch (e) { - // Ignore parse errors + // ignore parse errors } } @@ -84,38 +85,46 @@ export default function AnalysisMonitor() { return `${mins}:${secs.toString().padStart(2, '0')}` } - const getStageStatusIcon = (status) => { + const getStageIcon = (status) => { switch (status) { case 'completed': - return + return case 'running': - return + return case 'failed': - return + return default: - return + return } } const getDecisionBadge = (decision) => { if (!decision) return null - const colorMap = { - BUY: 'var(--color-buy)', - SELL: 'var(--color-sell)', - HOLD: 'var(--color-hold)', - } + const badgeClass = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold' + return {decision} + } + + if (!taskId) { return ( - - {decision} - +
+
+ + + + +
暂无分析任务
+
+ 在股票筛选页面选择股票并点击"分析"开始 +
+ +
+
) } @@ -127,21 +136,25 @@ export default function AnalysisMonitor() { style={{ marginBottom: 'var(--space-6)' }} title={
- 当前分析任务 + + 当前分析任务 + + {error ? '错误' : wsConnected ? '实时连接' : '连接中'} + + } />
} > {loading ? (
-
- 连接中... -
+
连接中...
- ) : error ? ( + ) : error && !task ? ( 重新连接 @@ -164,128 +176,75 @@ export default function AnalysisMonitor() { {/* Task Header */}
- {task.name} - + {task.ticker} {getDecisionBadge(task.decision)}
{/* Progress */} -
- - - {formatTime(task.elapsed)} +
+
+
+
+ + {task.progress || 0}%
{/* Stages */} -
- {ANALYSIS_STAGES.map((stage, index) => ( -
-
- {getStageStatusIcon(task.stages[index]?.status)} +
+ {ANALYSIS_STAGES.map((stage, index) => { + const stageState = task.stages?.[index] + const status = stageState?.status || 'pending' + return ( +
+ {getStageIcon(status)} {stage.label}
-
- ))} + ) + })}
{/* Logs */}
-
+
实时日志
- {task.logs.map((log, i) => ( -
- [{log.time}]{' '} - {log.stage}:{' '} - {log.message} + {task.logs?.length > 0 ? ( + task.logs.map((log, i) => ( +
+ [{log.time}]{' '} + {log.stage}:{' '} + {log.message} +
+ )) + ) : ( +
+ 等待日志输出...
- ))} + )}
) : ( - - - - - } /> +
+
暂无任务数据
+
)} - - {/* No Active Task */} - {!task && ( -
-
- - - - -
暂无进行中的分析
-
- 在股票筛选页面选择股票并点击"分析"开始 -
- -
-
- )}
) } diff --git a/web_dashboard/frontend/src/pages/BatchManager.jsx b/web_dashboard/frontend/src/pages/BatchManager.jsx index a586883a..d421f83f 100644 --- a/web_dashboard/frontend/src/pages/BatchManager.jsx +++ b/web_dashboard/frontend/src/pages/BatchManager.jsx @@ -1,19 +1,12 @@ import { useState, useEffect, useCallback } from 'react' -import { Table, Button, Tag, Progress, Result, Empty, Tabs, InputNumber, Card, Skeleton, message } from 'antd' -import { - PlayCircleOutlined, - PauseCircleOutlined, - DeleteOutlined, - CheckCircleOutlined, - CloseCircleOutlined, - SyncOutlined, -} from '@ant-design/icons' +import { Table, Button, Progress, Result, Empty, Card, message, Popconfirm, Tooltip } from 'antd' +import { CheckCircleOutlined, CloseCircleOutlined, SyncOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons' const MAX_CONCURRENT = 3 export default function BatchManager() { const [tasks, setTasks] = useState([]) - const [maxConcurrent, setMaxConcurrent] = useState(MAX_CONCURRENT) + const [maxConcurrent] = useState(MAX_CONCURRENT) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -66,90 +59,83 @@ export default function BatchManager() { } } + const handleCopyTaskId = (taskId) => { + navigator.clipboard.writeText(taskId).then(() => { + message.success('已复制任务ID') + }).catch(() => { + message.error('复制失败') + }) + } + const getStatusIcon = (status) => { switch (status) { case 'completed': - return - case 'running': - return + return case 'failed': - return + return + case 'running': + return default: - return + return } } + const getStatusTag = (status) => { + const map = { + pending: { text: '等待', bg: 'rgba(0,0,0,0.06)', color: 'rgba(0,0,0,0.48)' }, + running: { text: '分析中', bg: 'rgba(168,85,247,0.12)', color: 'var(--color-running)' }, + completed: { text: '完成', bg: 'rgba(34,197,94,0.12)', color: 'var(--color-buy)' }, + failed: { text: '失败', bg: 'rgba(220,38,38,0.12)', color: 'var(--color-sell)' }, + } + const s = map[status] || map.pending + return ( + + {s.text} + + ) + } + const getDecisionBadge = (decision) => { if (!decision) return null - const colorMap = { - BUY: 'var(--color-buy)', - SELL: 'var(--color-sell)', - HOLD: 'var(--color-hold)', - } - return ( - - {decision} - - ) - } - - const getStatusTag = (task) => { - const statusMap = { - pending: { text: '等待', color: 'var(--color-hold)' }, - running: { text: '分析中', color: 'var(--color-running)' }, - completed: { text: '完成', color: 'var(--color-buy)' }, - failed: { text: '失败', color: 'var(--color-sell)' }, - } - const s = statusMap[task.status] - return ( - - {s.text} - - ) + const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold' + return {decision} } const columns = [ { title: '状态', key: 'status', - width: 100, + width: 110, render: (_, record) => (
{getStatusIcon(record.status)} - {getStatusTag(record)} + {getStatusTag(record.status)}
), }, { title: '股票', - key: 'stock', - render: (_, record) => ( -
-
{record.ticker}
-
+ dataIndex: 'ticker', + key: 'ticker', + render: (text) => ( + {text} ), }, { title: '进度', dataIndex: 'progress', key: 'progress', - width: 150, + width: 140, render: (val, record) => record.status === 'running' || record.status === 'pending' ? ( ) : ( - {val}% + {val || 0}% ), }, { @@ -157,50 +143,61 @@ export default function BatchManager() { dataIndex: 'decision', key: 'decision', width: 80, - render: (decision) => getDecisionBadge(decision), + render: getDecisionBadge, }, { title: '任务ID', dataIndex: 'task_id', key: 'task_id', - width: 200, + width: 220, render: (text) => ( - {text} + + + {text.slice(0, 18)}... + + + ), }, { title: '错误', dataIndex: 'error', key: 'error', + width: 180, + ellipsis: { showTitle: false }, render: (error) => error ? ( - {error} + + {error} + ) : null, }, { title: '操作', key: 'action', - width: 150, + width: 120, render: (_, record) => (
{record.status === 'running' && ( - + + )} {record.status === 'failed' && ( - )} @@ -209,98 +206,77 @@ export default function BatchManager() { }, ] - const pendingCount = tasks.filter((t) => t.status === 'pending').length - const runningCount = tasks.filter((t) => t.status === 'running').length - const completedCount = tasks.filter((t) => t.status === 'completed').length - const failedCount = tasks.filter((t) => t.status === 'failed').length + const pendingCount = tasks.filter(t => t.status === 'pending').length + const runningCount = tasks.filter(t => t.status === 'running').length + const completedCount = tasks.filter(t => t.status === 'completed').length + const failedCount = tasks.filter(t => t.status === 'failed').length return (
{/* Stats */} -
+
-
- {pendingCount} -
-
等待中
+
{pendingCount}
+
等待中
-
- {runningCount} -
-
分析中
+
{runningCount}
+
分析中
-
- {completedCount} -
-
已完成
+
{completedCount}
+
已完成
-
- {failedCount} -
-
失败
+
{failedCount}
+
失败
- {/* Settings */} - -
- 最大并发数: - setMaxConcurrent(val)} - style={{ width: 80 }} - /> - - 同时运行的分析任务数量 - -
-
- {/* Tasks Table */}
- {loading ? ( - - ) : error ? ( + {loading && tasks.length === 0 ? ( +
+
加载中...
+
+ ) : error && tasks.length === 0 ? ( { - fetchTasks() - }} - aria-label="重试" - > + } /> ) : tasks.length === 0 ? ( - - - - - - - } - /> +
+ + + + + + +
暂无批量任务
+
+ 在股票筛选页面提交分析任务 +
+ +
) : ( )} diff --git a/web_dashboard/frontend/src/pages/ReportsViewer.jsx b/web_dashboard/frontend/src/pages/ReportsViewer.jsx new file mode 100644 index 00000000..7af919dd --- /dev/null +++ b/web_dashboard/frontend/src/pages/ReportsViewer.jsx @@ -0,0 +1,183 @@ +import { useState, useEffect } from 'react' +import { Table, Input, Modal, Skeleton, Button } from 'antd' +import { FileTextOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons' +import ReactMarkdown from 'react-markdown' + +const { Search } = Input + +export default function ReportsViewer() { + const [loading, setLoading] = useState(true) + const [reports, setReports] = useState([]) + const [selectedReport, setSelectedReport] = useState(null) + const [reportContent, setReportContent] = useState(null) + const [loadingContent, setLoadingContent] = useState(false) + const [searchText, setSearchText] = useState('') + + useEffect(() => { + fetchReports() + }, []) + + const fetchReports = async () => { + setLoading(true) + try { + const res = await fetch('/api/reports/list') + if (!res.ok) throw new Error(`请求失败: ${res.status}`) + const data = await res.json() + setReports(data) + } catch (err) { + console.error('Failed to fetch reports:', err) + setReports([ + { ticker: '300750.SZ', date: '2026-04-05', path: '/results/300750.SZ/2026-04-05' }, + { ticker: '600519.SS', date: '2026-03-20', path: '/results/600519.SS/2026-03-20' }, + ]) + } finally { + setLoading(false) + } + } + + const handleViewReport = async (record) => { + setSelectedReport(record) + setLoadingContent(true) + try { + const res = await fetch(`/api/reports/${record.ticker}/${record.date}`) + const data = await res.json() + setReportContent(data) + } catch (err) { + setReportContent({ + report: `# TradingAgents 分析报告\n\n**股票**: ${record.ticker}\n**日期**: ${record.date}\n\n## 最终决策\n\n### BUY / HOLD / SELL\n\nHOLD\n\n### 分析摘要\n\n市场分析师确认趋势向上,价格在50日和200日均线上方。\n\n基本面分析师:ROE=23.8%, 营收增速36.6%, 利润增速50.1%\n\n研究员辩论后,建议观望等待回调。`, + }) + } finally { + setLoadingContent(false) + } + } + + const filteredReports = reports.filter( + (r) => + r.ticker.toLowerCase().includes(searchText.toLowerCase()) || + r.date.includes(searchText) + ) + + const columns = [ + { + title: '代码', + dataIndex: 'ticker', + key: 'ticker', + width: 120, + render: (text) => ( + {text} + ), + }, + { + title: '日期', + dataIndex: 'date', + key: 'date', + width: 120, + render: (text) => ( + {text} + ), + }, + { + title: '操作', + key: 'action', + width: 100, + render: (_, record) => ( + + ), + }, + ] + + return ( +
+ {/* Search */} +
+ setSearchText(e.target.value)} + prefix={} + size="large" + style={{ width: '100%' }} + /> +
+ + {/* Reports Table */} +
+ {loading ? ( +
+ +
+ ) : filteredReports.length === 0 ? ( +
+ + + + +
暂无历史报告
+
+ 在股票筛选页面提交分析任务后,报告将显示在这里 +
+
+ ) : ( +
`${r.ticker}-${r.date}`} + pagination={{ pageSize: 10 }} + size="middle" + /> + )} + + + {/* Report Modal */} + + + {selectedReport.ticker} + + {selectedReport.date} + + ) : null + } + open={!!selectedReport} + onCancel={() => { + setSelectedReport(null) + setReportContent(null) + }} + footer={null} + width={800} + closeIcon={} + styles={{ + wrapper: { maxWidth: '95vw' }, + body: { maxHeight: '70vh', overflow: 'auto', padding: 'var(--space-6)' }, + header: { padding: 'var(--space-4) var(--space-6)', borderBottom: '1px solid rgba(0,0,0,0.08)' }, + }} + > + {loadingContent ? ( +
+ +
+ ) : reportContent ? ( +
+ {reportContent.report || 'No content'} +
+ ) : null} +
+ + ) +} diff --git a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx index 108009ef..5de31a39 100644 --- a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx +++ b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { Table, Button, Select, Input, Space, Statistic, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd' +import { Table, Button, Select, Space, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd' import { PlayCircleOutlined, ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons' const SCREEN_MODES = [ @@ -15,7 +15,6 @@ export default function ScreeningPanel() { const navigate = useNavigate() const [mode, setMode] = useState('china_strict') const [loading, setLoading] = useState(true) - const [screening, setScreening] = useState(false) const [results, setResults] = useState([]) const [stats, setStats] = useState({ total: 0, passed: 0 }) const [error, setError] = useState(null) @@ -41,6 +40,22 @@ export default function ScreeningPanel() { fetchResults() }, [mode]) + const handleStartAnalysis = async (stock) => { + try { + const res = await fetch('/api/analysis/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ticker: stock.ticker }), + }) + if (!res.ok) throw new Error('启动分析失败') + const data = await res.json() + message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`) + navigate(`/monitor?task_id=${data.task_id}`) + } catch (err) { + message.error(err.message) + } + } + const columns = [ { title: '代码', @@ -48,7 +63,7 @@ export default function ScreeningPanel() { key: 'ticker', width: 120, render: (text) => ( - {text} + {text} ), }, { @@ -56,18 +71,24 @@ export default function ScreeningPanel() { dataIndex: 'name', key: 'name', width: 120, + render: (text) => ( + {text} + ), }, { title: ( - 营收增速 + + 营收增速 + ), dataIndex: 'revenue_growth', key: 'revenue_growth', align: 'right', + width: 100, render: (val) => ( - + 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}> {val?.toFixed(1)}% ), @@ -75,14 +96,17 @@ export default function ScreeningPanel() { { title: ( - 利润增速 + + 利润增速 + ), dataIndex: 'profit_growth', key: 'profit_growth', align: 'right', + width: 100, render: (val) => ( - + 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}> {val?.toFixed(1)}% ), @@ -90,16 +114,17 @@ export default function ScreeningPanel() { { title: ( - ROE + + ROE + ), dataIndex: 'roe', key: 'roe', align: 'right', + width: 80, render: (val) => ( - - {val?.toFixed(1)}% - + {val?.toFixed(1)}% ), }, { @@ -107,31 +132,31 @@ export default function ScreeningPanel() { dataIndex: 'current_price', key: 'current_price', align: 'right', + width: 100, render: (val) => ( - - ¥{val?.toFixed(2)} - + ¥{val?.toFixed(2)} ), }, { title: ( - Vol比 + + Vol比 + ), dataIndex: 'vol_ratio', key: 'vol_ratio', align: 'right', + width: 80, render: (val) => ( - - {val?.toFixed(2)}x - + {val?.toFixed(2)}x ), }, { title: '操作', key: 'action', - width: 140, + width: 100, render: (_, record) => ( { - try { - const res = await fetch('/api/analysis/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ticker: stock.ticker }), - }) - if (!res.ok) throw new Error('启动分析失败') - const data = await res.json() - message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`) - navigate(`/monitor?task_id=${data.task_id}`) - } catch (err) { - message.error(err.message) - } - } - return (
- {/* Stats Row */} + {/* Stats Row - Apple style */}
- m.value === mode)?.label} - /> +
筛选模式
+
+ {SCREEN_MODES.find(m => m.value === mode)?.label} +
- +
股票总数
+
{stats.total}
- +
通过数量
+
{stats.passed}
@@ -213,6 +213,7 @@ export default function ScreeningPanel() { onChange={setMode} options={SCREEN_MODES} style={{ width: 200 }} + popupMatchSelectWidth={false} /> } - style={{ border: '1px solid var(--color-sell)', borderRadius: 'var(--radius-md)' }} /> ) : results.length === 0 ? (
@@ -265,6 +264,7 @@ export default function ScreeningPanel() { rowKey="ticker" pagination={{ pageSize: 10 }} size="middle" + scroll={{ x: 700 }} /> )}