refactor(dashboard): simplify components and fix efficiency issues

- Extract DecisionBadge and StatusIcon/StatusTag to shared components
  to eliminate duplication across BatchManager, AnalysisMonitor, PortfolioPanel
- Remove dead code: unused maxConcurrent state and formatTime function
- Add useMemo for columns (all pages) and derived stats (BatchManager, PortfolioPanel)
- Fix polling flash: BatchManager fetchTasks accepts showLoading param
- Fix RecommendationsTab: consolidate progress completion into connectWs handler,
  replace double-arrow cleanup with named cleanup function
- Extract DEFAULT_ACCOUNT constant to avoid magic strings
- Extract HEADER_LABEL_STYLE and HEADER_ICON_STYLE constants in ScreeningPanel
- Remove unused imports (CheckCircleOutlined, CloseCircleOutlined, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
陈少杰 2026-04-07 20:27:49 +08:00
parent 5c4d0a72fc
commit dd9392c9fb
7 changed files with 145 additions and 142 deletions

View File

@ -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 <span className={cls}>{decision}</span>
}

View File

@ -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 <CheckCircleOutlined style={{ color: 'var(--buy)', fontSize: 16 }} />
case 'failed':
return <CloseCircleOutlined style={{ color: 'var(--sell)', fontSize: 16 }} />
case 'running':
return <SyncOutlined spin style={{ color: 'var(--running)', fontSize: 16 }} />
default:
return (
<span
style={{
width: 16,
height: 16,
borderRadius: '50%',
border: '2px solid var(--border-strong)',
display: 'inline-block',
}}
/>
)
}
}
export function StatusTag({ status }) {
const s = STATUS_TAG_MAP[status] || STATUS_TAG_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>
)
}

View File

@ -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 <CheckCircleOutlined style={{ color: 'var(--color-buy)', fontSize: 16 }} />
case 'running':
return <SyncOutlined spin style={{ color: 'var(--color-running)', fontSize: 16 }} />
case 'failed':
return <CloseCircleOutlined style={{ color: 'var(--color-sell)', fontSize: 16 }} />
default:
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--border-strong)', display: 'inline-block' }} />
}
}
const getDecisionBadge = (decision) => {
if (!decision) return null
const badgeClass = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
return <span className={badgeClass}>{decision}</span>
}
if (!taskId) {
return (
<div className="card">
@ -179,7 +155,7 @@ export default function AnalysisMonitor() {
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 28, fontWeight: 600, letterSpacing: 0.196, lineHeight: 1.14 }}>
{task.ticker}
</span>
{getDecisionBadge(task.decision)}
<DecisionBadge decision={task.decision} />
</div>
{/* Progress */}
@ -200,7 +176,7 @@ export default function AnalysisMonitor() {
const status = stageState?.status || 'pending'
return (
<div key={stage.key} className={`stage-pill ${status}`}>
{getStageIcon(status)}
<StatusIcon status={status} />
<span>{stage.label}</span>
</div>
)

View File

@ -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 <CheckCircleOutlined style={{ color: 'var(--buy)', fontSize: 16 }} />
case 'failed':
return <CloseCircleOutlined style={{ color: 'var(--sell)', fontSize: 16 }} />
case 'running':
return <SyncOutlined spin style={{ color: 'var(--running)', fontSize: 16 }} />
default:
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--border-strong)', display: 'inline-block' }} />
}
}
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 (
<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 cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
return <span className={cls}>{decision}</span>
}
const columns = [
const columns = useMemo(() => [
{
title: '状态',
key: 'status',
width: 110,
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{getStatusIcon(record.status)}
{getStatusTag(record.status)}
</div>
<>
<StatusIcon status={record.status} />
<StatusTag status={record.status} />
</>
),
},
{
@ -143,7 +108,7 @@ export default function BatchManager() {
dataIndex: 'decision',
key: 'decision',
width: 80,
render: getDecisionBadge,
render: (decision) => <DecisionBadge decision={decision} />,
},
{
title: '任务ID',
@ -174,7 +139,7 @@ export default function BatchManager() {
render: (error) =>
error ? (
<Tooltip title={error} placement="topLeft">
<span style={{ color: 'var(--color-sell)', fontSize: 12, display: 'block' }}>{error}</span>
<span style={{ color: 'var(--sell)', fontSize: 12, display: 'block' }}>{error}</span>
</Tooltip>
) : null,
},
@ -204,16 +169,18 @@ export default function BatchManager() {
</div>
),
},
]
], [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 (
<div>
{/* Compact stat strip — no card nesting, left-aligned with colored accents */}
{/* Compact stat strip */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
@ -224,12 +191,12 @@ export default function BatchManager() {
padding: 'var(--space-1)',
}}>
{[
{ 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 }) => (
<div key={label} style={{
{ label: '等待中', key: 'pending', color: 'var(--text-muted)', border: 'var(--text-muted)' },
{ label: '分析中', key: 'running', color: 'var(--running)', border: 'var(--running)' },
{ label: '已完成', key: 'completed', color: 'var(--buy)', border: 'var(--buy)' },
{ label: '失败', key: 'failed', color: 'var(--sell)', border: 'var(--sell)' },
].map(({ label, key, color, border }) => (
<div key={key} style={{
background: 'var(--bg-surface)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-3) var(--space-4)',
@ -239,7 +206,7 @@ export default function BatchManager() {
}}>
<div style={{ width: 3, height: 32, background: border, borderRadius: 2, flexShrink: 0 }} />
<div>
<div className="text-data" style={{ fontSize: 22, fontWeight: 600, color, lineHeight: 1 }}>{value}</div>
<div className="text-data" style={{ fontSize: 22, fontWeight: 600, color, lineHeight: 1 }}>{stats[key]}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{label}</div>
</div>
</div>
@ -258,7 +225,7 @@ export default function BatchManager() {
title="加载失败"
subTitle={error}
extra={
<Button type="primary" onClick={fetchTasks}>
<Button type="primary" onClick={() => fetchTasks(true)}>
重试
</Button>
}

View File

@ -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 <span className={cls}>{decision}</span>
}
// ============== 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,

View File

@ -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() {
</Button>
),
},
]
], [])
return (
<div>

View File

@ -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: (
<Tooltip title="营业收入同比增长率">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
<span style={HEADER_LABEL_STYLE}>
营收增速 <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
</span>
</Tooltip>
),
@ -88,7 +91,7 @@ export default function ScreeningPanel() {
align: 'right',
width: 100,
render: (val) => (
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
<span className="text-data" style={{ color: val > 0 ? 'var(--buy)' : 'var(--sell)' }}>
{val?.toFixed(1)}%
</span>
),
@ -96,8 +99,8 @@ export default function ScreeningPanel() {
{
title: (
<Tooltip title="净利润同比增长率">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
<span style={HEADER_LABEL_STYLE}>
利润增速 <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
</span>
</Tooltip>
),
@ -106,7 +109,7 @@ export default function ScreeningPanel() {
align: 'right',
width: 100,
render: (val) => (
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
<span className="text-data" style={{ color: val > 0 ? 'var(--buy)' : 'var(--sell)' }}>
{val?.toFixed(1)}%
</span>
),
@ -114,8 +117,8 @@ export default function ScreeningPanel() {
{
title: (
<Tooltip title="净资产收益率 = 净利润/净资产">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
<span style={HEADER_LABEL_STYLE}>
ROE <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
</span>
</Tooltip>
),
@ -140,8 +143,8 @@ export default function ScreeningPanel() {
{
title: (
<Tooltip title="当前成交量/过去20日平均成交量">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
<span style={HEADER_LABEL_STYLE}>
Vol比 <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
</span>
</Tooltip>
),
@ -175,7 +178,7 @@ export default function ScreeningPanel() {
</Popconfirm>
),
},
]
], [])
return (
<div>