-
+
实时日志
- {task.logs.map((log, i) => (
-
-
[{log.time}]{' '}
-
{log.stage}:{' '}
-
{log.message}
+ {task.logs?.length > 0 ? (
+ task.logs.map((log, i) => (
+
+ [{log.time}]{' '}
+ {log.stage}:{' '}
+ {log.message}
+
+ ))
+ ) : (
+
+ 等待日志输出...
- ))}
+ )}
>
) : (
-
-
-
-
- } />
+
)}
-
- {/* No Active Task */}
- {!task && (
-
-
-
-
暂无进行中的分析
-
- 在股票筛选页面选择股票并点击"分析"开始
-
-
-
-
- )}
)
}
diff --git a/web_dashboard/frontend/src/pages/BatchManager.jsx b/web_dashboard/frontend/src/pages/BatchManager.jsx
index a586883a..d421f83f 100644
--- a/web_dashboard/frontend/src/pages/BatchManager.jsx
+++ b/web_dashboard/frontend/src/pages/BatchManager.jsx
@@ -1,19 +1,12 @@
import { useState, useEffect, useCallback } from 'react'
-import { Table, Button, Tag, Progress, Result, Empty, Tabs, InputNumber, Card, Skeleton, message } from 'antd'
-import {
- PlayCircleOutlined,
- PauseCircleOutlined,
- DeleteOutlined,
- CheckCircleOutlined,
- CloseCircleOutlined,
- SyncOutlined,
-} from '@ant-design/icons'
+import { Table, Button, Progress, Result, Empty, Card, message, Popconfirm, Tooltip } from 'antd'
+import { CheckCircleOutlined, CloseCircleOutlined, SyncOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
const MAX_CONCURRENT = 3
export default function BatchManager() {
const [tasks, setTasks] = useState([])
- const [maxConcurrent, setMaxConcurrent] = useState(MAX_CONCURRENT)
+ const [maxConcurrent] = useState(MAX_CONCURRENT)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -66,90 +59,83 @@ export default function BatchManager() {
}
}
+ const handleCopyTaskId = (taskId) => {
+ navigator.clipboard.writeText(taskId).then(() => {
+ message.success('已复制任务ID')
+ }).catch(() => {
+ message.error('复制失败')
+ })
+ }
+
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
- return
- case 'running':
- return
+ return
case 'failed':
- return
+ return
+ case 'running':
+ return
default:
- return
+ return
}
}
+ const getStatusTag = (status) => {
+ const map = {
+ pending: { text: '等待', bg: 'rgba(0,0,0,0.06)', color: 'rgba(0,0,0,0.48)' },
+ running: { text: '分析中', bg: 'rgba(168,85,247,0.12)', color: 'var(--color-running)' },
+ completed: { text: '完成', bg: 'rgba(34,197,94,0.12)', color: 'var(--color-buy)' },
+ failed: { text: '失败', bg: 'rgba(220,38,38,0.12)', color: 'var(--color-sell)' },
+ }
+ const s = map[status] || map.pending
+ return (
+
+ {s.text}
+
+ )
+ }
+
const getDecisionBadge = (decision) => {
if (!decision) return null
- const colorMap = {
- BUY: 'var(--color-buy)',
- SELL: 'var(--color-sell)',
- HOLD: 'var(--color-hold)',
- }
- return (
-
- {decision}
-
- )
- }
-
- const getStatusTag = (task) => {
- const statusMap = {
- pending: { text: '等待', color: 'var(--color-hold)' },
- running: { text: '分析中', color: 'var(--color-running)' },
- completed: { text: '完成', color: 'var(--color-buy)' },
- failed: { text: '失败', color: 'var(--color-sell)' },
- }
- const s = statusMap[task.status]
- return (
-
- {s.text}
-
- )
+ const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
+ return
{decision}
}
const columns = [
{
title: '状态',
key: 'status',
- width: 100,
+ width: 110,
render: (_, record) => (
{getStatusIcon(record.status)}
- {getStatusTag(record)}
+ {getStatusTag(record.status)}
),
},
{
title: '股票',
- key: 'stock',
- render: (_, record) => (
-
+ dataIndex: 'ticker',
+ key: 'ticker',
+ render: (text) => (
+
{text}
),
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
- width: 150,
+ width: 140,
render: (val, record) =>
record.status === 'running' || record.status === 'pending' ? (
) : (
-
{val}%
+
{val || 0}%
),
},
{
@@ -157,50 +143,61 @@ export default function BatchManager() {
dataIndex: 'decision',
key: 'decision',
width: 80,
- render: (decision) => getDecisionBadge(decision),
+ render: getDecisionBadge,
},
{
title: '任务ID',
dataIndex: 'task_id',
key: 'task_id',
- width: 200,
+ width: 220,
render: (text) => (
-
{text}
+
+
+ {text.slice(0, 18)}...
+
+
+
),
},
{
title: '错误',
dataIndex: 'error',
key: 'error',
+ width: 180,
+ ellipsis: { showTitle: false },
render: (error) =>
error ? (
-
{error}
+
+ {error}
+
) : null,
},
{
title: '操作',
key: 'action',
- width: 150,
+ width: 120,
render: (_, record) => (
{record.status === 'running' && (
-
}
- onClick={() => handleCancel(record.task_id)}
- aria-label="取消"
+
handleCancel(record.task_id)}
+ okText="确认"
+ cancelText="取消"
>
- 取消
-
+ }>
+ 取消
+
+
)}
{record.status === 'failed' && (
-
}
- onClick={() => handleRetry(record.task_id)}
- aria-label="重试"
- >
+
} onClick={() => handleRetry(record.task_id)}>
重试
)}
@@ -209,98 +206,77 @@ export default function BatchManager() {
},
]
- const pendingCount = tasks.filter((t) => t.status === 'pending').length
- const runningCount = tasks.filter((t) => t.status === 'running').length
- const completedCount = tasks.filter((t) => t.status === 'completed').length
- const failedCount = tasks.filter((t) => t.status === 'failed').length
+ const pendingCount = tasks.filter(t => t.status === 'pending').length
+ const runningCount = tasks.filter(t => t.status === 'running').length
+ const completedCount = tasks.filter(t => t.status === 'completed').length
+ const failedCount = tasks.filter(t => t.status === 'failed').length
return (
{/* Stats */}
-
+
-
- {pendingCount}
-
- 等待中
+ {pendingCount}
+ 等待中
-
- {runningCount}
-
- 分析中
+ {runningCount}
+ 分析中
-
- {completedCount}
-
- 已完成
+ {completedCount}
+ 已完成
-
- {failedCount}
-
- 失败
+ {failedCount}
+ 失败
- {/* Settings */}
-
-
- 最大并发数:
- setMaxConcurrent(val)}
- style={{ width: 80 }}
- />
-
- 同时运行的分析任务数量
-
-
-
-
{/* Tasks Table */}
- {loading ? (
-
- ) : error ? (
+ {loading && tasks.length === 0 ? (
+
+ ) : error && tasks.length === 0 ? (
{
- fetchTasks()
- }}
- aria-label="重试"
- >
+
}
/>
) : tasks.length === 0 ? (
-
-
-
-
-
-
- }
- />
+
+
+
暂无批量任务
+
+ 在股票筛选页面提交分析任务
+
+
+
) : (
)}
diff --git a/web_dashboard/frontend/src/pages/ReportsViewer.jsx b/web_dashboard/frontend/src/pages/ReportsViewer.jsx
new file mode 100644
index 00000000..7af919dd
--- /dev/null
+++ b/web_dashboard/frontend/src/pages/ReportsViewer.jsx
@@ -0,0 +1,183 @@
+import { useState, useEffect } from 'react'
+import { Table, Input, Modal, Skeleton, Button } from 'antd'
+import { FileTextOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
+import ReactMarkdown from 'react-markdown'
+
+const { Search } = Input
+
+export default function ReportsViewer() {
+ const [loading, setLoading] = useState(true)
+ const [reports, setReports] = useState([])
+ const [selectedReport, setSelectedReport] = useState(null)
+ const [reportContent, setReportContent] = useState(null)
+ const [loadingContent, setLoadingContent] = useState(false)
+ const [searchText, setSearchText] = useState('')
+
+ useEffect(() => {
+ fetchReports()
+ }, [])
+
+ const fetchReports = async () => {
+ setLoading(true)
+ try {
+ const res = await fetch('/api/reports/list')
+ if (!res.ok) throw new Error(`请求失败: ${res.status}`)
+ const data = await res.json()
+ setReports(data)
+ } catch (err) {
+ console.error('Failed to fetch reports:', err)
+ setReports([
+ { ticker: '300750.SZ', date: '2026-04-05', path: '/results/300750.SZ/2026-04-05' },
+ { ticker: '600519.SS', date: '2026-03-20', path: '/results/600519.SS/2026-03-20' },
+ ])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleViewReport = async (record) => {
+ setSelectedReport(record)
+ setLoadingContent(true)
+ try {
+ const res = await fetch(`/api/reports/${record.ticker}/${record.date}`)
+ const data = await res.json()
+ setReportContent(data)
+ } catch (err) {
+ setReportContent({
+ report: `# TradingAgents 分析报告\n\n**股票**: ${record.ticker}\n**日期**: ${record.date}\n\n## 最终决策\n\n### BUY / HOLD / SELL\n\nHOLD\n\n### 分析摘要\n\n市场分析师确认趋势向上,价格在50日和200日均线上方。\n\n基本面分析师:ROE=23.8%, 营收增速36.6%, 利润增速50.1%\n\n研究员辩论后,建议观望等待回调。`,
+ })
+ } finally {
+ setLoadingContent(false)
+ }
+ }
+
+ const filteredReports = reports.filter(
+ (r) =>
+ r.ticker.toLowerCase().includes(searchText.toLowerCase()) ||
+ r.date.includes(searchText)
+ )
+
+ const columns = [
+ {
+ title: '代码',
+ dataIndex: 'ticker',
+ key: 'ticker',
+ width: 120,
+ render: (text) => (
+
{text}
+ ),
+ },
+ {
+ title: '日期',
+ dataIndex: 'date',
+ key: 'date',
+ width: 120,
+ render: (text) => (
+
{text}
+ ),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 100,
+ render: (_, record) => (
+
}
+ size="small"
+ onClick={() => handleViewReport(record)}
+ >
+ 查看
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* Search */}
+
+ setSearchText(e.target.value)}
+ prefix={}
+ size="large"
+ style={{ width: '100%' }}
+ />
+
+
+ {/* Reports Table */}
+
+ {loading ? (
+
+
+
+ ) : filteredReports.length === 0 ? (
+
+
+
暂无历史报告
+
+ 在股票筛选页面提交分析任务后,报告将显示在这里
+
+
+ ) : (
+
`${r.ticker}-${r.date}`}
+ pagination={{ pageSize: 10 }}
+ size="middle"
+ />
+ )}
+
+
+ {/* Report Modal */}
+
+
+ {selectedReport.ticker}
+
+ {selectedReport.date}
+
+ ) : null
+ }
+ open={!!selectedReport}
+ onCancel={() => {
+ setSelectedReport(null)
+ setReportContent(null)
+ }}
+ footer={null}
+ width={800}
+ closeIcon={}
+ styles={{
+ wrapper: { maxWidth: '95vw' },
+ body: { maxHeight: '70vh', overflow: 'auto', padding: 'var(--space-6)' },
+ header: { padding: 'var(--space-4) var(--space-6)', borderBottom: '1px solid rgba(0,0,0,0.08)' },
+ }}
+ >
+ {loadingContent ? (
+
+
+
+ ) : reportContent ? (
+
+ {reportContent.report || 'No content'}
+
+ ) : null}
+
+
+ )
+}
diff --git a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx
index 108009ef..5de31a39 100644
--- a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx
+++ b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
-import { Table, Button, Select, Input, Space, Statistic, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd'
+import { Table, Button, Select, Space, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd'
import { PlayCircleOutlined, ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons'
const SCREEN_MODES = [
@@ -15,7 +15,6 @@ export default function ScreeningPanel() {
const navigate = useNavigate()
const [mode, setMode] = useState('china_strict')
const [loading, setLoading] = useState(true)
- const [screening, setScreening] = useState(false)
const [results, setResults] = useState([])
const [stats, setStats] = useState({ total: 0, passed: 0 })
const [error, setError] = useState(null)
@@ -41,6 +40,22 @@ export default function ScreeningPanel() {
fetchResults()
}, [mode])
+ const handleStartAnalysis = async (stock) => {
+ try {
+ const res = await fetch('/api/analysis/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ticker: stock.ticker }),
+ })
+ if (!res.ok) throw new Error('启动分析失败')
+ const data = await res.json()
+ message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`)
+ navigate(`/monitor?task_id=${data.task_id}`)
+ } catch (err) {
+ message.error(err.message)
+ }
+ }
+
const columns = [
{
title: '代码',
@@ -48,7 +63,7 @@ export default function ScreeningPanel() {
key: 'ticker',
width: 120,
render: (text) => (
- {text}
+ {text}
),
},
{
@@ -56,18 +71,24 @@ export default function ScreeningPanel() {
dataIndex: 'name',
key: 'name',
width: 120,
+ render: (text) => (
+ {text}
+ ),
},
{
title: (
- 营收增速
+
+ 营收增速
+
),
dataIndex: 'revenue_growth',
key: 'revenue_growth',
align: 'right',
+ width: 100,
render: (val) => (
-
+ 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{val?.toFixed(1)}%
),
@@ -75,14 +96,17 @@ export default function ScreeningPanel() {
{
title: (
- 利润增速
+
+ 利润增速
+
),
dataIndex: 'profit_growth',
key: 'profit_growth',
align: 'right',
+ width: 100,
render: (val) => (
-
+ 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{val?.toFixed(1)}%
),
@@ -90,16 +114,17 @@ export default function ScreeningPanel() {
{
title: (
- ROE
+
+ ROE
+
),
dataIndex: 'roe',
key: 'roe',
align: 'right',
+ width: 80,
render: (val) => (
-
- {val?.toFixed(1)}%
-
+ {val?.toFixed(1)}%
),
},
{
@@ -107,31 +132,31 @@ export default function ScreeningPanel() {
dataIndex: 'current_price',
key: 'current_price',
align: 'right',
+ width: 100,
render: (val) => (
-
- ¥{val?.toFixed(2)}
-
+ ¥{val?.toFixed(2)}
),
},
{
title: (
- Vol比
+
+ Vol比
+
),
dataIndex: 'vol_ratio',
key: 'vol_ratio',
align: 'right',
+ width: 80,
render: (val) => (
-
- {val?.toFixed(2)}x
-
+ {val?.toFixed(2)}x
),
},
{
title: '操作',
key: 'action',
- width: 140,
+ width: 100,
render: (_, record) => (
{
- try {
- const res = await fetch('/api/analysis/start', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ticker: stock.ticker }),
- })
- if (!res.ok) throw new Error('启动分析失败')
- const data = await res.json()
- message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`)
- navigate(`/monitor?task_id=${data.task_id}`)
- } catch (err) {
- message.error(err.message)
- }
- }
-
return (
- {/* Stats Row */}
+ {/* Stats Row - Apple style */}
-
m.value === mode)?.label}
- />
+ 筛选模式
+
+ {SCREEN_MODES.find(m => m.value === mode)?.label}
+
-
+
通过数量
+
{stats.passed}
@@ -213,6 +213,7 @@ export default function ScreeningPanel() {
onChange={setMode}
options={SCREEN_MODES}
style={{ width: 200 }}
+ popupMatchSelectWidth={false}
/>
}
@@ -239,12 +240,10 @@ export default function ScreeningPanel() {
type="primary"
icon={
}
onClick={fetchResults}
- aria-label="重试"
>
重试
}
- style={{ border: '1px solid var(--color-sell)', borderRadius: 'var(--radius-md)' }}
/>
) : results.length === 0 ? (
@@ -265,6 +264,7 @@ export default function ScreeningPanel() {
rowKey="ticker"
pagination={{ pageSize: 10 }}
size="middle"
+ scroll={{ x: 700 }}
/>
)}