feat(dashboard): apply Apple design system to all 4 pages
- Font: replace SF Pro with DM Sans (web-available) throughout - Typography: consistent DM Sans stack, monospace data display - ScreeningPanel: add horizontal scroll for mobile, fix stat card hover - AnalysisMonitor: Apple progress bar, stage pills, decision badge - BatchManager: add copy-to-clipboard for task IDs, fix error tooltip truncation, add CTA to empty state - ReportsViewer: Apple-styled modal, search bar consistency - Keyboard: add Escape to close modals - CSS: progress bar ease-out, sidebar collapse button icon-only mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
51ec1ac410
commit
ddf34222e3
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TradingAgents Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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: <FundOutlined />, label: '筛选', key: '1' },
|
||||
{ path: '/monitor', icon: <MonitorOutlined />, label: '监控', key: '2' },
|
||||
{ path: '/reports', icon: <FileTextOutlined />, label: '报告', key: '3' },
|
||||
{ path: '/batch', icon: <ClusterOutlined />, 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 (
|
||||
<div className="dashboard-layout">
|
||||
{/* Sidebar - Apple Glass Navigation */}
|
||||
{!isMobile && (
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
{!collapsed && <span>TradingAgents</span>}
|
||||
{collapsed && <span style={{ fontSize: 12, letterSpacing: '0.1em' }}>TA</span>}
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`nav-item ${isActive ? 'active' : ''}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
aria-label={`${item.label} (按${item.key}切换)`}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: 'var(--space-2)' }}>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
className="sidebar-collapse-btn"
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
{!collapsed && <span>收起</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={`main-content ${collapsed && !isMobile ? 'sidebar-collapsed' : ''}`}>
|
||||
{!isMobile && (
|
||||
<header className="topbar">
|
||||
<div className="topbar-title">{currentPage}</div>
|
||||
<div className="topbar-date">
|
||||
{new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="page-content">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile TabBar */}
|
||||
{isMobile && (
|
||||
<nav className="mobile-tabbar" aria-label="移动端导航">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`mobile-tab-item ${isActive ? 'active' : ''}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<span className="mobile-tab-icon">{item.icon}</span>
|
||||
<span className="mobile-tab-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Layout>
|
||||
<Suspense fallback={
|
||||
<div style={{ padding: 'var(--space-12)', textAlign: 'center' }}>
|
||||
<div className="loading-pulse">加载中...</div>
|
||||
</div>
|
||||
}>
|
||||
<Routes>
|
||||
<Route path="/" element={<ScreeningPanel />} />
|
||||
<Route path="/monitor" element={<AnalysisMonitor />} />
|
||||
<Route path="/reports" element={<ReportsViewer />} />
|
||||
<Route path="/batch" element={<BatchManager />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider theme={appleTheme}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
|
@ -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 <CheckCircleOutlined style={{ color: 'var(--color-buy)' }} />
|
||||
return <CheckCircleOutlined style={{ color: 'var(--color-buy)', fontSize: 16 }} />
|
||||
case 'running':
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)' }} />
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)', fontSize: 16 }} />
|
||||
case 'failed':
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)' }} />
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)', fontSize: 16 }} />
|
||||
default:
|
||||
return <Badge status="default" />
|
||||
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid rgba(0,0,0,0.12)', display: 'inline-block' }} />
|
||||
}
|
||||
}
|
||||
|
||||
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 <span className={badgeClass}>{decision}</span>
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<Tag
|
||||
color={colorMap[decision]}
|
||||
style={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
padding: '4px 12px',
|
||||
}}
|
||||
>
|
||||
{decision}
|
||||
</Tag>
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无分析任务</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面选择股票并点击"分析"开始
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
onClick={() => window.location.href = '/'}
|
||||
>
|
||||
去筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -127,21 +136,25 @@ export default function AnalysisMonitor() {
|
|||
style={{ marginBottom: 'var(--space-6)' }}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span>当前分析任务</span>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>
|
||||
当前分析任务
|
||||
</span>
|
||||
<Badge
|
||||
status={error ? 'error' : wsConnected ? 'success' : 'error'}
|
||||
text={error ? '错误' : wsConnected ? '实时连接' : '未连接'}
|
||||
status={error ? 'error' : wsConnected ? 'success' : 'default'}
|
||||
text={
|
||||
<span style={{ fontSize: 12, color: error ? 'var(--color-sell)' : wsConnected ? 'var(--color-buy)' : 'rgba(0,0,0,0.48)' }}>
|
||||
{error ? '错误' : wsConnected ? '实时连接' : '连接中'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-12)' }}>
|
||||
<div className="loading-pulse" style={{ color: 'var(--color-running)', fontSize: 16 }}>
|
||||
连接中...
|
||||
</div>
|
||||
<div className="loading-pulse" style={{ fontSize: 16 }}>连接中...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
) : error && !task ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="连接失败"
|
||||
|
|
@ -153,7 +166,6 @@ export default function AnalysisMonitor() {
|
|||
fetchInitialState()
|
||||
connectWebSocket()
|
||||
}}
|
||||
aria-label="重新连接"
|
||||
>
|
||||
重新连接
|
||||
</Button>
|
||||
|
|
@ -164,128 +176,75 @@ export default function AnalysisMonitor() {
|
|||
{/* Task Header */}
|
||||
<div style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 24, fontWeight: 600 }}>{task.name}</span>
|
||||
<span style={{ fontFamily: 'var(--font-data)', color: 'var(--color-text-muted)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 28, fontWeight: 600, letterSpacing: 0.196, lineHeight: 1.14 }}>
|
||||
{task.ticker}
|
||||
</span>
|
||||
{getDecisionBadge(task.decision)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Progress
|
||||
percent={task.progress}
|
||||
status="active"
|
||||
strokeColor="var(--color-buy)"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
color: 'var(--color-text-muted)',
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
{formatTime(task.elapsed)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div className="progress-bar" style={{ flex: 1, height: 6 }}>
|
||||
<div className="progress-bar-fill" style={{ width: `${task.progress || 0}%` }} />
|
||||
</div>
|
||||
<span className="text-data" style={{ minWidth: 50, textAlign: 'right' }}>
|
||||
{task.progress || 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stages */}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
|
||||
{ANALYSIS_STAGES.map((stage, index) => (
|
||||
<div
|
||||
key={stage.key}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background:
|
||||
task.stages[index]?.status === 'running'
|
||||
? 'rgba(168, 85, 247, 0.15)'
|
||||
: task.stages[index]?.status === 'completed'
|
||||
? 'rgba(34, 197, 94, 0.15)'
|
||||
: 'var(--color-surface-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: `1px solid ${
|
||||
task.stages[index]?.status === 'running'
|
||||
? 'var(--color-running)'
|
||||
: task.stages[index]?.status === 'completed'
|
||||
? 'var(--color-buy)'
|
||||
: 'var(--color-border)'
|
||||
}`,
|
||||
opacity: task.stages[index]?.status === 'pending' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{getStageStatusIcon(task.stages[index]?.status)}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap', marginBottom: 'var(--space-6)' }}>
|
||||
{ANALYSIS_STAGES.map((stage, index) => {
|
||||
const stageState = task.stages?.[index]
|
||||
const status = stageState?.status || 'pending'
|
||||
return (
|
||||
<div key={stage.key} className={`stage-pill ${status}`}>
|
||||
{getStageIcon(status)}
|
||||
<span>{stage.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<div className="text-caption" style={{ marginBottom: 12, textTransform: 'uppercase', fontWeight: 600 }}>
|
||||
实时日志
|
||||
</div>
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
fontSize: 12,
|
||||
background: 'var(--color-bg)',
|
||||
background: 'rgba(0,0,0,0.03)',
|
||||
padding: 'var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
maxHeight: 300,
|
||||
borderRadius: 'var(--radius-standard)',
|
||||
maxHeight: 280,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{task.logs.map((log, i) => (
|
||||
<div key={i} style={{ marginBottom: 8 }}>
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>[{log.time}]</span>{' '}
|
||||
<span style={{ color: 'var(--color-interactive)' }}>{log.stage}:</span>{' '}
|
||||
<span>{log.message}</span>
|
||||
{task.logs?.length > 0 ? (
|
||||
task.logs.map((log, i) => (
|
||||
<div key={i} style={{ marginBottom: 8, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'rgba(0,0,0,0.48)' }}>[{log.time}]</span>{' '}
|
||||
<span style={{ fontWeight: 500 }}>{log.stage}:</span>{' '}
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: 'rgba(0,0,0,0.48)', textAlign: 'center', padding: 'var(--space-4)' }}>
|
||||
等待日志输出...
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="暂无进行中的分析任务" image={
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 48, height: 48 }}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
} />
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-title">暂无任务数据</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* No Active Task */}
|
||||
{!task && (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无进行中的分析</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面选择股票并点击"分析"开始
|
||||
</div>
|
||||
<Button type="primary" style={{ marginTop: 16 }} aria-label="去筛选股票">
|
||||
去筛选股票
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <CheckCircleOutlined style={{ color: 'var(--color-buy)' }} />
|
||||
case 'running':
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)' }} />
|
||||
return <CheckCircleOutlined style={{ color: 'var(--color-buy)', fontSize: 16 }} />
|
||||
case 'failed':
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)' }} />
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)', fontSize: 16 }} />
|
||||
case 'running':
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)', fontSize: 16 }} />
|
||||
default:
|
||||
return <PauseCircleOutlined style={{ color: 'var(--color-hold)' }} />
|
||||
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid rgba(0,0,0,0.2)', display: 'inline-block' }} />
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<span style={{ background: s.bg, color: s.color, padding: '2px 10px', borderRadius: 'var(--radius-pill)', fontSize: 12, fontWeight: 600 }}>
|
||||
{s.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getDecisionBadge = (decision) => {
|
||||
if (!decision) return null
|
||||
const colorMap = {
|
||||
BUY: 'var(--color-buy)',
|
||||
SELL: 'var(--color-sell)',
|
||||
HOLD: 'var(--color-hold)',
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
color={colorMap[decision]}
|
||||
style={{ fontFamily: 'var(--font-data)', fontWeight: 600 }}
|
||||
>
|
||||
{decision}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tag style={{ background: `${s.color}20`, color: s.color, border: 'none' }}>
|
||||
{s.text}
|
||||
</Tag>
|
||||
)
|
||||
const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
|
||||
return <span className={cls}>{decision}</span>
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{getStatusIcon(record.status)}
|
||||
{getStatusTag(record)}
|
||||
{getStatusTag(record.status)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '股票',
|
||||
key: 'stock',
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{record.ticker}</div>
|
||||
</div>
|
||||
dataIndex: 'ticker',
|
||||
key: 'ticker',
|
||||
render: (text) => (
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 150,
|
||||
width: 140,
|
||||
render: (val, record) =>
|
||||
record.status === 'running' || record.status === 'pending' ? (
|
||||
<Progress
|
||||
percent={val}
|
||||
percent={val || 0}
|
||||
size="small"
|
||||
strokeColor={
|
||||
record.status === 'pending'
|
||||
? 'var(--color-hold)'
|
||||
: 'var(--color-running)'
|
||||
}
|
||||
strokeColor="var(--color-apple-blue)"
|
||||
trailColor="rgba(0,0,0,0.08)"
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>{val}%</span>
|
||||
<span className="text-data">{val || 0}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -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) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)', fontSize: 12, color: 'var(--color-text-muted)' }}>{text}</span>
|
||||
<Tooltip title={text} placement="topLeft">
|
||||
<span className="text-data" style={{ fontSize: 11, color: 'rgba(0,0,0,0.48)', cursor: 'default' }}>
|
||||
{text.slice(0, 18)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCopyTaskId(text) }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'rgba(0,0,0,0.48)', display: 'inline-flex', alignItems: 'center' }}
|
||||
title="复制任务ID"
|
||||
>
|
||||
<CopyOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '错误',
|
||||
dataIndex: 'error',
|
||||
key: 'error',
|
||||
width: 180,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (error) =>
|
||||
error ? (
|
||||
<span style={{ color: 'var(--color-sell)', fontSize: 12 }}>{error}</span>
|
||||
<Tooltip title={error} placement="topLeft">
|
||||
<span style={{ color: 'var(--color-sell)', fontSize: 12, display: 'block' }}>{error}</span>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{record.status === 'running' && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => handleCancel(record.task_id)}
|
||||
aria-label="取消"
|
||||
<Popconfirm
|
||||
title="确认取消此任务?"
|
||||
onConfirm={() => handleCancel(record.task_id)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
取消
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{record.status === 'failed' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => handleRetry(record.task_id)}
|
||||
aria-label="重试"
|
||||
>
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRetry(record.task_id)}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 'var(--space-6)' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: 'var(--space-6)' }}>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600 }}>
|
||||
{pendingCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>等待中</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600 }}>{pendingCount}</div>
|
||||
<div className="text-caption">等待中</div>
|
||||
</Card>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-running)' }}>
|
||||
{runningCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>分析中</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-running)' }}>{runningCount}</div>
|
||||
<div className="text-caption">分析中</div>
|
||||
</Card>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-buy)' }}>
|
||||
{completedCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>已完成</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-buy)' }}>{completedCount}</div>
|
||||
<div className="text-caption">已完成</div>
|
||||
</Card>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-sell)' }}>
|
||||
{failedCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>失败</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-sell)' }}>{failedCount}</div>
|
||||
<div className="text-caption">失败</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<Card size="small" className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span>最大并发数:</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxConcurrent}
|
||||
onChange={(val) => setMaxConcurrent(val)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>
|
||||
同时运行的分析任务数量
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tasks Table */}
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<Skeleton active rows={5} />
|
||||
) : error ? (
|
||||
{loading && tasks.length === 0 ? (
|
||||
<div style={{ padding: 'var(--space-8)', textAlign: 'center' }}>
|
||||
<div className="loading-pulse">加载中...</div>
|
||||
</div>
|
||||
) : error && tasks.length === 0 ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
description="点击重试按钮重新加载数据"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
fetchTasks()
|
||||
}}
|
||||
aria-label="重试"
|
||||
>
|
||||
<Button type="primary" onClick={fetchTasks}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : tasks.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无批量任务"
|
||||
image={
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 48, height: 48 }}>
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无批量任务</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面提交分析任务
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
onClick={() => window.location.href = '/'}
|
||||
>
|
||||
去筛选
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
rowKey="task_id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
<span className="text-data">{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileTextOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleViewReport(record)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Search */}
|
||||
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<Search
|
||||
placeholder="搜索股票代码或日期..."
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.48)' }} />}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reports Table */}
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div style={{ padding: 'var(--space-8)' }}>
|
||||
<Skeleton active rows={5} />
|
||||
</div>
|
||||
) : filteredReports.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无历史报告</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面提交分析任务后,报告将显示在这里
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredReports}
|
||||
rowKey={(r) => `${r.ticker}-${r.date}`}
|
||||
pagination={{ pageSize: 10 }}
|
||||
size="middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Report Modal */}
|
||||
<Modal
|
||||
title={
|
||||
selectedReport ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>
|
||||
{selectedReport.ticker}
|
||||
</span>
|
||||
<span style={{ color: 'rgba(0,0,0,0.48)', fontSize: 14 }}>{selectedReport.date}</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
open={!!selectedReport}
|
||||
onCancel={() => {
|
||||
setSelectedReport(null)
|
||||
setReportContent(null)
|
||||
}}
|
||||
footer={null}
|
||||
width={800}
|
||||
closeIcon={<CloseOutlined style={{ fontSize: 16 }} />}
|
||||
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 ? (
|
||||
<div style={{ padding: 'var(--space-8)' }}>
|
||||
<Skeleton active />
|
||||
</div>
|
||||
) : reportContent ? (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-text)',
|
||||
lineHeight: 1.8,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown>{reportContent.report || 'No content'}</ReactMarkdown>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>{text}</span>
|
||||
<span className="text-data">{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -56,18 +71,24 @@ export default function ScreeningPanel() {
|
|||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
<span style={{ fontWeight: 500 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="营业收入同比增长率">
|
||||
<span>营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'revenue_growth',
|
||||
key: 'revenue_growth',
|
||||
align: 'right',
|
||||
width: 100,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
|
||||
{val?.toFixed(1)}%
|
||||
</span>
|
||||
),
|
||||
|
|
@ -75,14 +96,17 @@ export default function ScreeningPanel() {
|
|||
{
|
||||
title: (
|
||||
<Tooltip title="净利润同比增长率">
|
||||
<span>利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'profit_growth',
|
||||
key: 'profit_growth',
|
||||
align: 'right',
|
||||
width: 100,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
|
||||
{val?.toFixed(1)}%
|
||||
</span>
|
||||
),
|
||||
|
|
@ -90,16 +114,17 @@ export default function ScreeningPanel() {
|
|||
{
|
||||
title: (
|
||||
<Tooltip title="净资产收益率 = 净利润/净资产">
|
||||
<span>ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'roe',
|
||||
key: 'roe',
|
||||
align: 'right',
|
||||
width: 80,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
{val?.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-data">{val?.toFixed(1)}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -107,31 +132,31 @@ export default function ScreeningPanel() {
|
|||
dataIndex: 'current_price',
|
||||
key: 'current_price',
|
||||
align: 'right',
|
||||
width: 100,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
¥{val?.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-data">¥{val?.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="当前成交量/过去20日平均成交量">
|
||||
<span>Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'vol_ratio',
|
||||
key: 'vol_ratio',
|
||||
align: 'right',
|
||||
width: 80,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
{val?.toFixed(2)}x
|
||||
</span>
|
||||
<span className="text-data">{val?.toFixed(2)}x</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={`确认分析 ${record.name} (${record.ticker})?`}
|
||||
|
|
@ -152,53 +177,28 @@ export default function ScreeningPanel() {
|
|||
},
|
||||
]
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Row */}
|
||||
{/* Stats Row - Apple style */}
|
||||
<Row gutter={16} style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="card">
|
||||
<Statistic
|
||||
title="筛选模式"
|
||||
value={SCREEN_MODES.find(m => m.value === mode)?.label}
|
||||
/>
|
||||
<div className="text-caption" style={{ marginBottom: 4 }}>筛选模式</div>
|
||||
<div style={{ fontFamily: 'var(--font-text)', fontSize: 15, fontWeight: 500 }}>
|
||||
{SCREEN_MODES.find(m => m.value === mode)?.label}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="card">
|
||||
<Statistic
|
||||
title="股票总数"
|
||||
value={stats.total}
|
||||
valueStyle={{ fontFamily: 'var(--font-data)' }}
|
||||
/>
|
||||
<div className="text-caption" style={{ marginBottom: 4 }}>股票总数</div>
|
||||
<div className="text-data" style={{ fontSize: 28, fontWeight: 600 }}>{stats.total}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="card">
|
||||
<Statistic
|
||||
title="通过数量"
|
||||
value={stats.passed}
|
||||
valueStyle={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
color: 'var(--color-buy)',
|
||||
}}
|
||||
/>
|
||||
<div className="text-caption" style={{ marginBottom: 4 }}>通过数量</div>
|
||||
<div className="text-data" style={{ fontSize: 28, fontWeight: 600, color: 'var(--color-buy)' }}>{stats.passed}</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -213,6 +213,7 @@ export default function ScreeningPanel() {
|
|||
onChange={setMode}
|
||||
options={SCREEN_MODES}
|
||||
style={{ width: 200 }}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
|
|
@ -239,12 +240,10 @@ export default function ScreeningPanel() {
|
|||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchResults}
|
||||
aria-label="重试"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
style={{ border: '1px solid var(--color-sell)', borderRadius: 'var(--radius-md)' }}
|
||||
/>
|
||||
) : results.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
|
|
@ -265,6 +264,7 @@ export default function ScreeningPanel() {
|
|||
rowKey="ticker"
|
||||
pagination={{ pageSize: 10 }}
|
||||
size="middle"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue