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:
parent
f196962112
commit
04ac20ca69
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { Card, Progress, Badge, Empty, Button, Result, message } from 'antd'
|
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 = [
|
const ANALYSIS_STAGES = [
|
||||||
{ key: 'analysts', label: '分析师团队' },
|
{ key: 'analysts', label: '分析师团队' },
|
||||||
|
|
@ -79,31 +80,6 @@ export default function AnalysisMonitor() {
|
||||||
}
|
}
|
||||||
}, [taskId, fetchInitialState, connectWebSocket])
|
}, [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) {
|
if (!taskId) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<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 }}>
|
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 28, fontWeight: 600, letterSpacing: 0.196, lineHeight: 1.14 }}>
|
||||||
{task.ticker}
|
{task.ticker}
|
||||||
</span>
|
</span>
|
||||||
{getDecisionBadge(task.decision)}
|
<DecisionBadge decision={task.decision} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
|
|
@ -200,7 +176,7 @@ export default function AnalysisMonitor() {
|
||||||
const status = stageState?.status || 'pending'
|
const status = stageState?.status || 'pending'
|
||||||
return (
|
return (
|
||||||
<div key={stage.key} className={`stage-pill ${status}`}>
|
<div key={stage.key} className={`stage-pill ${status}`}>
|
||||||
{getStageIcon(status)}
|
<StatusIcon status={status} />
|
||||||
<span>{stage.label}</span>
|
<span>{stage.label}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { Table, Button, Progress, Result, Empty, Card, message, Popconfirm, Tooltip } from 'antd'
|
import { Table, Button, Progress, Result, Card, message, Popconfirm, Tooltip } from 'antd'
|
||||||
import { CheckCircleOutlined, CloseCircleOutlined, SyncOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
|
||||||
|
import DecisionBadge from '../components/DecisionBadge'
|
||||||
const MAX_CONCURRENT = 3
|
import { StatusIcon, StatusTag } from '../components/StatusIcon'
|
||||||
|
|
||||||
export default function BatchManager() {
|
export default function BatchManager() {
|
||||||
const [tasks, setTasks] = useState([])
|
const [tasks, setTasks] = useState([])
|
||||||
const [maxConcurrent] = useState(MAX_CONCURRENT)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const fetchTasks = useCallback(async () => {
|
const fetchTasks = useCallback(async (showLoading = true) => {
|
||||||
setLoading(true)
|
if (showLoading) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/analysis/tasks')
|
const res = await fetch('/api/analysis/tasks')
|
||||||
if (!res.ok) throw new Error('获取任务列表失败')
|
if (!res.ok) throw new Error('获取任务列表失败')
|
||||||
|
|
@ -21,13 +20,13 @@ export default function BatchManager() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (showLoading) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTasks()
|
fetchTasks(true)
|
||||||
const interval = setInterval(fetchTasks, 5000)
|
const interval = setInterval(() => fetchTasks(false), 5000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [fetchTasks])
|
}, [fetchTasks])
|
||||||
|
|
||||||
|
|
@ -36,7 +35,7 @@ export default function BatchManager() {
|
||||||
const res = await fetch(`/api/analysis/cancel/${taskId}`, { method: 'DELETE' })
|
const res = await fetch(`/api/analysis/cancel/${taskId}`, { method: 'DELETE' })
|
||||||
if (!res.ok) throw new Error('取消失败')
|
if (!res.ok) throw new Error('取消失败')
|
||||||
message.success('任务已取消')
|
message.success('任务已取消')
|
||||||
fetchTasks()
|
fetchTasks(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(err.message)
|
message.error(err.message)
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +52,7 @@ export default function BatchManager() {
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('重试失败')
|
if (!res.ok) throw new Error('重试失败')
|
||||||
message.success('任务已重新提交')
|
message.success('任务已重新提交')
|
||||||
fetchTasks()
|
fetchTasks(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(err.message)
|
message.error(err.message)
|
||||||
}
|
}
|
||||||
|
|
@ -67,50 +66,16 @@ export default function BatchManager() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (status) => {
|
const columns = useMemo(() => [
|
||||||
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 = [
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<>
|
||||||
{getStatusIcon(record.status)}
|
<StatusIcon status={record.status} />
|
||||||
{getStatusTag(record.status)}
|
<StatusTag status={record.status} />
|
||||||
</div>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -143,7 +108,7 @@ export default function BatchManager() {
|
||||||
dataIndex: 'decision',
|
dataIndex: 'decision',
|
||||||
key: 'decision',
|
key: 'decision',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: getDecisionBadge,
|
render: (decision) => <DecisionBadge decision={decision} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '任务ID',
|
title: '任务ID',
|
||||||
|
|
@ -174,7 +139,7 @@ export default function BatchManager() {
|
||||||
render: (error) =>
|
render: (error) =>
|
||||||
error ? (
|
error ? (
|
||||||
<Tooltip title={error} placement="topLeft">
|
<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>
|
</Tooltip>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
|
|
@ -204,16 +169,18 @@ export default function BatchManager() {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
], [tasks]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const pendingCount = tasks.filter(t => t.status === 'pending').length
|
const stats = useMemo(() => ({
|
||||||
const runningCount = tasks.filter(t => t.status === 'running').length
|
pending: tasks.filter(t => t.status === 'pending').length,
|
||||||
const completedCount = tasks.filter(t => t.status === 'completed').length
|
running: tasks.filter(t => t.status === 'running').length,
|
||||||
const failedCount = tasks.filter(t => t.status === 'failed').length
|
completed: tasks.filter(t => t.status === 'completed').length,
|
||||||
|
failed: tasks.filter(t => t.status === 'failed').length,
|
||||||
|
}), [tasks])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Compact stat strip — no card nesting, left-aligned with colored accents */}
|
{/* Compact stat strip */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
|
@ -224,12 +191,12 @@ export default function BatchManager() {
|
||||||
padding: 'var(--space-1)',
|
padding: 'var(--space-1)',
|
||||||
}}>
|
}}>
|
||||||
{[
|
{[
|
||||||
{ label: '等待中', value: pendingCount, color: 'var(--text-muted)', border: 'var(--text-muted)' },
|
{ label: '等待中', key: 'pending', color: 'var(--text-muted)', border: 'var(--text-muted)' },
|
||||||
{ label: '分析中', value: runningCount, color: 'var(--running)', border: 'var(--running)' },
|
{ label: '分析中', key: 'running', color: 'var(--running)', border: 'var(--running)' },
|
||||||
{ label: '已完成', value: completedCount, color: 'var(--buy)', border: 'var(--buy)' },
|
{ label: '已完成', key: 'completed', color: 'var(--buy)', border: 'var(--buy)' },
|
||||||
{ label: '失败', value: failedCount, color: 'var(--sell)', border: 'var(--sell)' },
|
{ label: '失败', key: 'failed', color: 'var(--sell)', border: 'var(--sell)' },
|
||||||
].map(({ label, value, color, border }) => (
|
].map(({ label, key, color, border }) => (
|
||||||
<div key={label} style={{
|
<div key={key} style={{
|
||||||
background: 'var(--bg-surface)',
|
background: 'var(--bg-surface)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
padding: 'var(--space-3) var(--space-4)',
|
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 style={{ width: 3, height: 32, background: border, borderRadius: 2, flexShrink: 0 }} />
|
||||||
<div>
|
<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 style={{ fontSize: 'var(--text-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{label}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -258,7 +225,7 @@ export default function BatchManager() {
|
||||||
title="加载失败"
|
title="加载失败"
|
||||||
subTitle={error}
|
subTitle={error}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={fetchTasks}>
|
<Button type="primary" onClick={() => fetchTasks(true)}>
|
||||||
重试
|
重试
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Table, Button, Input, Select, Space, Row, Col, Card, Progress, Result,
|
Table, Button, Input, Select, Space, Row, Col, Card, Progress,
|
||||||
message, Popconfirm, Modal, Tabs, Tag, Tooltip, Upload, Form, Typography,
|
message, Popconfirm, Modal, Tabs, Tooltip, Form, Typography,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
PlusOutlined, DeleteOutlined, PlayCircleOutlined, UploadOutlined,
|
PlusOutlined, DeleteOutlined, PlayCircleOutlined, UploadOutlined,
|
||||||
DownloadOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
DownloadOutlined, SyncOutlined, AccountBookOutlined,
|
||||||
AccountBookOutlined,
|
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { portfolioApi } from '../services/portfolioApi'
|
import { portfolioApi } from '../services/portfolioApi'
|
||||||
|
import DecisionBadge from '../components/DecisionBadge'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
// ============== Helpers ==============
|
const DEFAULT_ACCOUNT = '默认账户'
|
||||||
|
|
||||||
const formatMoney = (v) =>
|
const formatMoney = (v) =>
|
||||||
v == null ? '—' : `¥${v.toFixed(2)}`;
|
v == null ? '—' : `¥${v.toFixed(2)}`;
|
||||||
|
|
@ -20,12 +20,6 @@ const formatMoney = (v) =>
|
||||||
const formatPct = (v) =>
|
const formatPct = (v) =>
|
||||||
v == null ? '—' : `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`;
|
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 ==============
|
// ============== Tab 1: Watchlist ==============
|
||||||
|
|
||||||
function WatchlistTab() {
|
function WatchlistTab() {
|
||||||
|
|
@ -129,7 +123,7 @@ function WatchlistTab() {
|
||||||
|
|
||||||
function PositionsTab() {
|
function PositionsTab() {
|
||||||
const [data, setData] = useState([])
|
const [data, setData] = useState([])
|
||||||
const [accounts, setAccounts] = useState(['默认账户'])
|
const [accounts, setAccounts] = useState([DEFAULT_ACCOUNT])
|
||||||
const [account, setAccount] = useState(null)
|
const [account, setAccount] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
|
@ -143,7 +137,7 @@ function PositionsTab() {
|
||||||
portfolioApi.getAccounts(),
|
portfolioApi.getAccounts(),
|
||||||
])
|
])
|
||||||
setData(posRes.positions || [])
|
setData(posRes.positions || [])
|
||||||
setAccounts(accRes.accounts || ['默认账户'])
|
setAccounts(accRes.accounts || [DEFAULT_ACCOUNT])
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载失败')
|
message.error('加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -155,7 +149,7 @@ function PositionsTab() {
|
||||||
|
|
||||||
const handleAdd = async (vals) => {
|
const handleAdd = async (vals) => {
|
||||||
try {
|
try {
|
||||||
await portfolioApi.addPosition({ ...vals, account: account || '默认账户' })
|
await portfolioApi.addPosition({ ...vals, account: account || DEFAULT_ACCOUNT })
|
||||||
message.success('已添加')
|
message.success('已添加')
|
||||||
setAddOpen(false)
|
setAddOpen(false)
|
||||||
form.resetFields()
|
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 = [
|
const columns = [
|
||||||
{ title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
|
{ title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
|
||||||
|
|
@ -342,11 +336,19 @@ function RecommendationsTab() {
|
||||||
ws.onopen = () => setWsConnected(true)
|
ws.onopen = () => setWsConnected(true)
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const d = JSON.parse(e.data)
|
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)
|
ws.onclose = () => setWsConnected(false)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
}, [])
|
}, [fetchRecs, selectedDate])
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -362,15 +364,13 @@ function RecommendationsTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (progress?.status === 'completed' || progress?.status === 'failed') {
|
return () => {
|
||||||
setAnalyzing(false)
|
if (wsRef.current) {
|
||||||
setTaskId(null)
|
wsRef.current.close()
|
||||||
setProgress(null)
|
wsRef.current = null
|
||||||
fetchRecs(selectedDate)
|
}
|
||||||
}
|
}
|
||||||
}, [progress?.status])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => () => { if (wsRef.current) wsRef.current.close() }, [])
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
|
{ title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
|
||||||
|
|
|
||||||
|
|
@ -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 { Table, Input, Modal, Skeleton, Button, Space, message } from 'antd'
|
||||||
import { FileTextOutlined, SearchOutlined, CloseOutlined, DownloadOutlined } from '@ant-design/icons'
|
import { FileTextOutlined, SearchOutlined, CloseOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
|
@ -74,13 +74,16 @@ export default function ReportsViewer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredReports = reports.filter(
|
const filteredReports = useMemo(() =>
|
||||||
(r) =>
|
reports.filter(
|
||||||
r.ticker.toLowerCase().includes(searchText.toLowerCase()) ||
|
(r) =>
|
||||||
r.date.includes(searchText)
|
r.ticker.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
r.date.includes(searchText)
|
||||||
|
),
|
||||||
|
[reports, searchText]
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '代码',
|
title: '代码',
|
||||||
dataIndex: 'ticker',
|
dataIndex: 'ticker',
|
||||||
|
|
@ -114,7 +117,7 @@ export default function ReportsViewer() {
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
], [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Table, Button, Select, Space, 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'
|
import { PlayCircleOutlined, ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
||||||
|
|
@ -11,6 +11,9 @@ const SCREEN_MODES = [
|
||||||
{ value: 'fundamentals_only', label: '纯基本面 (Fundamentals Only)' },
|
{ 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() {
|
export default function ScreeningPanel() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [mode, setMode] = useState('china_strict')
|
const [mode, setMode] = useState('china_strict')
|
||||||
|
|
@ -56,7 +59,7 @@ export default function ScreeningPanel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '代码',
|
title: '代码',
|
||||||
dataIndex: 'ticker',
|
dataIndex: 'ticker',
|
||||||
|
|
@ -78,8 +81,8 @@ export default function ScreeningPanel() {
|
||||||
{
|
{
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="营业收入同比增长率">
|
<Tooltip title="营业收入同比增长率">
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
<span style={HEADER_LABEL_STYLE}>
|
||||||
营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
|
营收增速 <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
|
|
@ -88,7 +91,7 @@ export default function ScreeningPanel() {
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (val) => (
|
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)}%
|
{val?.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
|
|
@ -96,8 +99,8 @@ export default function ScreeningPanel() {
|
||||||
{
|
{
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="净利润同比增长率">
|
<Tooltip title="净利润同比增长率">
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
<span style={HEADER_LABEL_STYLE}>
|
||||||
利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
|
利润增速 <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
|
|
@ -106,7 +109,7 @@ export default function ScreeningPanel() {
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (val) => (
|
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)}%
|
{val?.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
|
|
@ -114,8 +117,8 @@ export default function ScreeningPanel() {
|
||||||
{
|
{
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="净资产收益率 = 净利润/净资产">
|
<Tooltip title="净资产收益率 = 净利润/净资产">
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
<span style={HEADER_LABEL_STYLE}>
|
||||||
ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
|
ROE <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
|
|
@ -140,8 +143,8 @@ export default function ScreeningPanel() {
|
||||||
{
|
{
|
||||||
title: (
|
title: (
|
||||||
<Tooltip title="当前成交量/过去20日平均成交量">
|
<Tooltip title="当前成交量/过去20日平均成交量">
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
<span style={HEADER_LABEL_STYLE}>
|
||||||
Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--text-muted)' }} />
|
Vol比 <QuestionCircleOutlined style={HEADER_ICON_STYLE} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
|
|
@ -175,7 +178,7 @@ export default function ScreeningPanel() {
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
], [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue