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:
陈少杰 2026-04-06 23:47:01 +08:00
parent 51ec1ac410
commit ddf34222e3
8 changed files with 1542 additions and 317 deletions

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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;
}

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>