diff --git a/web_dashboard/frontend/src/components/DecisionBadge.jsx b/web_dashboard/frontend/src/components/DecisionBadge.jsx new file mode 100644 index 00000000..d5ebf8bf --- /dev/null +++ b/web_dashboard/frontend/src/components/DecisionBadge.jsx @@ -0,0 +1,5 @@ +export default function DecisionBadge({ decision }) { + if (!decision) return null + const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold' + return {decision} +} diff --git a/web_dashboard/frontend/src/components/StatusIcon.jsx b/web_dashboard/frontend/src/components/StatusIcon.jsx new file mode 100644 index 00000000..696056af --- /dev/null +++ b/web_dashboard/frontend/src/components/StatusIcon.jsx @@ -0,0 +1,49 @@ +import { CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons' + +const STATUS_TAG_MAP = { + pending: { text: '等待', bg: 'var(--bg-elevated)', color: 'var(--text-muted)' }, + running: { text: '分析中', bg: 'var(--running-dim)', color: 'var(--running)' }, + completed: { text: '完成', bg: 'var(--buy-dim)', color: 'var(--buy)' }, + failed: { text: '失败', bg: 'var(--sell-dim)', color: 'var(--sell)' }, +} + +export function StatusIcon({ status }) { + switch (status) { + case 'completed': + return + case 'failed': + return + case 'running': + return + default: + return ( + + ) + } +} + +export function StatusTag({ status }) { + const s = STATUS_TAG_MAP[status] || STATUS_TAG_MAP.pending + return ( + + {s.text} + + ) +} diff --git a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx index beba4760..f91cc27e 100644 --- a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx +++ b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { Card, Progress, Badge, Empty, Button, Result, message } from 'antd' -import { CheckCircleOutlined, SyncOutlined, CloseCircleOutlined } from '@ant-design/icons' +import DecisionBadge from '../components/DecisionBadge' +import { StatusIcon } from '../components/StatusIcon' const ANALYSIS_STAGES = [ { key: 'analysts', label: '分析师团队' }, @@ -79,31 +80,6 @@ export default function AnalysisMonitor() { } }, [taskId, fetchInitialState, connectWebSocket]) - const formatTime = (seconds) => { - const mins = Math.floor(seconds / 60) - const secs = seconds % 60 - return `${mins}:${secs.toString().padStart(2, '0')}` - } - - const getStageIcon = (status) => { - switch (status) { - case 'completed': - return - case 'running': - return - case 'failed': - return - default: - return - } - } - - const getDecisionBadge = (decision) => { - if (!decision) return null - const badgeClass = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold' - return {decision} - } - if (!taskId) { return (
@@ -179,7 +155,7 @@ export default function AnalysisMonitor() { {task.ticker} - {getDecisionBadge(task.decision)} +
{/* Progress */} @@ -200,7 +176,7 @@ export default function AnalysisMonitor() { const status = stageState?.status || 'pending' return (
- {getStageIcon(status)} + {stage.label}
) diff --git a/web_dashboard/frontend/src/pages/BatchManager.jsx b/web_dashboard/frontend/src/pages/BatchManager.jsx index 084a18fe..22098670 100644 --- a/web_dashboard/frontend/src/pages/BatchManager.jsx +++ b/web_dashboard/frontend/src/pages/BatchManager.jsx @@ -1,17 +1,16 @@ -import { useState, useEffect, useCallback } from 'react' -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 +import { useState, useEffect, useCallback, useMemo } from 'react' +import { Table, Button, Progress, Result, Card, message, Popconfirm, Tooltip } from 'antd' +import { DeleteOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons' +import DecisionBadge from '../components/DecisionBadge' +import { StatusIcon, StatusTag } from '../components/StatusIcon' export default function BatchManager() { const [tasks, setTasks] = useState([]) - const [maxConcurrent] = useState(MAX_CONCURRENT) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const fetchTasks = useCallback(async () => { - setLoading(true) + const fetchTasks = useCallback(async (showLoading = true) => { + if (showLoading) setLoading(true) try { const res = await fetch('/api/analysis/tasks') if (!res.ok) throw new Error('获取任务列表失败') @@ -21,13 +20,13 @@ export default function BatchManager() { } catch (err) { setError(err.message) } finally { - setLoading(false) + if (showLoading) setLoading(false) } }, []) useEffect(() => { - fetchTasks() - const interval = setInterval(fetchTasks, 5000) + fetchTasks(true) + const interval = setInterval(() => fetchTasks(false), 5000) return () => clearInterval(interval) }, [fetchTasks]) @@ -36,7 +35,7 @@ export default function BatchManager() { const res = await fetch(`/api/analysis/cancel/${taskId}`, { method: 'DELETE' }) if (!res.ok) throw new Error('取消失败') message.success('任务已取消') - fetchTasks() + fetchTasks(false) } catch (err) { message.error(err.message) } @@ -53,7 +52,7 @@ export default function BatchManager() { }) if (!res.ok) throw new Error('重试失败') message.success('任务已重新提交') - fetchTasks() + fetchTasks(false) } catch (err) { message.error(err.message) } @@ -67,50 +66,16 @@ export default function BatchManager() { }) } - const getStatusIcon = (status) => { - switch (status) { - case 'completed': - return - case 'failed': - return - case 'running': - return - default: - return - } - } - - const getStatusTag = (status) => { - const map = { - pending: { text: '等待', bg: 'var(--bg-elevated)', color: 'var(--text-muted)' }, - running: { text: '分析中', bg: 'var(--running-dim)', color: 'var(--running)' }, - completed: { text: '完成', bg: 'var(--buy-dim)', color: 'var(--buy)' }, - failed: { text: '失败', bg: 'var(--sell-dim)', color: 'var(--sell)' }, - } - const s = map[status] || map.pending - return ( - - {s.text} - - ) - } - - const getDecisionBadge = (decision) => { - if (!decision) return null - const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold' - return {decision} - } - - const columns = [ + const columns = useMemo(() => [ { title: '状态', key: 'status', width: 110, render: (_, record) => ( -
- {getStatusIcon(record.status)} - {getStatusTag(record.status)} -
+ <> + + + ), }, { @@ -143,7 +108,7 @@ export default function BatchManager() { dataIndex: 'decision', key: 'decision', width: 80, - render: getDecisionBadge, + render: (decision) => , }, { title: '任务ID', @@ -174,7 +139,7 @@ export default function BatchManager() { render: (error) => error ? ( - {error} + {error} ) : null, }, @@ -204,16 +169,18 @@ export default function BatchManager() { ), }, - ] + ], [tasks]) // eslint-disable-line react-hooks/exhaustive-deps - 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 stats = useMemo(() => ({ + pending: tasks.filter(t => t.status === 'pending').length, + running: tasks.filter(t => t.status === 'running').length, + completed: tasks.filter(t => t.status === 'completed').length, + failed: tasks.filter(t => t.status === 'failed').length, + }), [tasks]) return (
- {/* Compact stat strip — no card nesting, left-aligned with colored accents */} + {/* Compact stat strip */}
{[ - { label: '等待中', value: pendingCount, color: 'var(--text-muted)', border: 'var(--text-muted)' }, - { label: '分析中', value: runningCount, color: 'var(--running)', border: 'var(--running)' }, - { label: '已完成', value: completedCount, color: 'var(--buy)', border: 'var(--buy)' }, - { label: '失败', value: failedCount, color: 'var(--sell)', border: 'var(--sell)' }, - ].map(({ label, value, color, border }) => ( -
( +
-
{value}
+
{stats[key]}
{label}
@@ -258,7 +225,7 @@ export default function BatchManager() { title="加载失败" subTitle={error} extra={ - } diff --git a/web_dashboard/frontend/src/pages/PortfolioPanel.jsx b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx index 1e439891..98ba6383 100644 --- a/web_dashboard/frontend/src/pages/PortfolioPanel.jsx +++ b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx @@ -1,18 +1,18 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { - Table, Button, Input, Select, Space, Row, Col, Card, Progress, Result, - message, Popconfirm, Modal, Tabs, Tag, Tooltip, Upload, Form, Typography, + Table, Button, Input, Select, Space, Row, Col, Card, Progress, + message, Popconfirm, Modal, Tabs, Tooltip, Form, Typography, } from 'antd' import { PlusOutlined, DeleteOutlined, PlayCircleOutlined, UploadOutlined, - DownloadOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, - AccountBookOutlined, + DownloadOutlined, SyncOutlined, AccountBookOutlined, } from '@ant-design/icons' import { portfolioApi } from '../services/portfolioApi' +import DecisionBadge from '../components/DecisionBadge' const { Text } = Typography -// ============== Helpers ============== +const DEFAULT_ACCOUNT = '默认账户' const formatMoney = (v) => v == null ? '—' : `¥${v.toFixed(2)}`; @@ -20,12 +20,6 @@ const formatMoney = (v) => const formatPct = (v) => v == null ? '—' : `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`; -const DecisionBadge = ({ decision }) => { - if (!decision) return null - const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold' - return {decision} -} - // ============== Tab 1: Watchlist ============== function WatchlistTab() { @@ -129,7 +123,7 @@ function WatchlistTab() { function PositionsTab() { const [data, setData] = useState([]) - const [accounts, setAccounts] = useState(['默认账户']) + const [accounts, setAccounts] = useState([DEFAULT_ACCOUNT]) const [account, setAccount] = useState(null) const [loading, setLoading] = useState(true) const [addOpen, setAddOpen] = useState(false) @@ -143,7 +137,7 @@ function PositionsTab() { portfolioApi.getAccounts(), ]) setData(posRes.positions || []) - setAccounts(accRes.accounts || ['默认账户']) + setAccounts(accRes.accounts || [DEFAULT_ACCOUNT]) } catch { message.error('加载失败') } finally { @@ -155,7 +149,7 @@ function PositionsTab() { const handleAdd = async (vals) => { try { - await portfolioApi.addPosition({ ...vals, account: account || '默认账户' }) + await portfolioApi.addPosition({ ...vals, account: account || DEFAULT_ACCOUNT }) message.success('已添加') setAddOpen(false) form.resetFields() @@ -187,7 +181,7 @@ function PositionsTab() { } } - const totalPnl = data.reduce((s, p) => s + (p.unrealized_pnl || 0), 0) + const totalPnl = useMemo(() => data.reduce((s, p) => s + (p.unrealized_pnl || 0), 0), [data]) const columns = [ { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110, @@ -342,11 +336,19 @@ function RecommendationsTab() { ws.onopen = () => setWsConnected(true) ws.onmessage = (e) => { const d = JSON.parse(e.data) - if (d.type === 'progress') setProgress(d) + if (d.type === 'progress') { + setProgress(d) + if (d.status === 'completed' || d.status === 'failed') { + setAnalyzing(false) + setTaskId(null) + setProgress(null) + fetchRecs(selectedDate) + } + } } ws.onclose = () => setWsConnected(false) wsRef.current = ws - }, []) + }, [fetchRecs, selectedDate]) const handleAnalyze = async () => { try { @@ -362,15 +364,13 @@ function RecommendationsTab() { } useEffect(() => { - if (progress?.status === 'completed' || progress?.status === 'failed') { - setAnalyzing(false) - setTaskId(null) - setProgress(null) - fetchRecs(selectedDate) + return () => { + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } } - }, [progress?.status]) - - useEffect(() => () => { if (wsRef.current) wsRef.current.close() }, []) + }, []) const columns = [ { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110, diff --git a/web_dashboard/frontend/src/pages/ReportsViewer.jsx b/web_dashboard/frontend/src/pages/ReportsViewer.jsx index 66936922..dc0bcae0 100644 --- a/web_dashboard/frontend/src/pages/ReportsViewer.jsx +++ b/web_dashboard/frontend/src/pages/ReportsViewer.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { Table, Input, Modal, Skeleton, Button, Space, message } from 'antd' import { FileTextOutlined, SearchOutlined, CloseOutlined, DownloadOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' @@ -74,13 +74,16 @@ export default function ReportsViewer() { } } - const filteredReports = reports.filter( - (r) => - r.ticker.toLowerCase().includes(searchText.toLowerCase()) || - r.date.includes(searchText) + const filteredReports = useMemo(() => + reports.filter( + (r) => + r.ticker.toLowerCase().includes(searchText.toLowerCase()) || + r.date.includes(searchText) + ), + [reports, searchText] ) - const columns = [ + const columns = useMemo(() => [ { title: '代码', dataIndex: 'ticker', @@ -114,7 +117,7 @@ export default function ReportsViewer() { ), }, - ] + ], []) return (
diff --git a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx index b0ba1413..453245aa 100644 --- a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx +++ b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { Table, Button, Select, Space, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd' import { PlayCircleOutlined, ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons' @@ -11,6 +11,9 @@ const SCREEN_MODES = [ { value: 'fundamentals_only', label: '纯基本面 (Fundamentals Only)' }, ] +const HEADER_LABEL_STYLE = { display: 'inline-flex', alignItems: 'center', gap: 4 } +const HEADER_ICON_STYLE = { fontSize: 10, color: 'var(--text-muted)' } + export default function ScreeningPanel() { const navigate = useNavigate() const [mode, setMode] = useState('china_strict') @@ -56,7 +59,7 @@ export default function ScreeningPanel() { } } - const columns = [ + const columns = useMemo(() => [ { title: '代码', dataIndex: 'ticker', @@ -78,8 +81,8 @@ export default function ScreeningPanel() { { title: ( - - 营收增速 + + 营收增速 ), @@ -88,7 +91,7 @@ export default function ScreeningPanel() { align: 'right', width: 100, render: (val) => ( - 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}> + 0 ? 'var(--buy)' : 'var(--sell)' }}> {val?.toFixed(1)}% ), @@ -96,8 +99,8 @@ export default function ScreeningPanel() { { title: ( - - 利润增速 + + 利润增速 ), @@ -106,7 +109,7 @@ export default function ScreeningPanel() { align: 'right', width: 100, render: (val) => ( - 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}> + 0 ? 'var(--buy)' : 'var(--sell)' }}> {val?.toFixed(1)}% ), @@ -114,8 +117,8 @@ export default function ScreeningPanel() { { title: ( - - ROE + + ROE ), @@ -140,8 +143,8 @@ export default function ScreeningPanel() { { title: ( - - Vol比 + + Vol比 ), @@ -175,7 +178,7 @@ export default function ScreeningPanel() { ), }, - ] + ], []) return (