TradingAgents/web_dashboard/frontend/src/pages/PortfolioPanel.jsx

469 lines
16 KiB
JavaScript

import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
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, AccountBookOutlined,
} from '@ant-design/icons'
import { portfolioApi } from '../services/portfolioApi'
import DecisionBadge from '../components/DecisionBadge'
import { getDecision, getDisplayDate, isCompletedLikeStatus } from '../utils/contractView'
const { Text } = Typography
const DEFAULT_ACCOUNT = '默认账户'
const formatMoney = (v) =>
v == null ? '—' : `¥${v.toFixed(2)}`;
const formatPct = (v) =>
v == null ? '—' : `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`;
// ============== Tab 1: Watchlist ==============
function WatchlistTab() {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false)
const [form] = Form.useForm()
const fetch_ = useCallback(async () => {
setLoading(true)
try {
const res = await portfolioApi.getWatchlist()
setData(res.watchlist || [])
} catch {
message.error('加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetch_() }, [fetch_])
const handleAdd = async (vals) => {
try {
await portfolioApi.addToWatchlist(vals.ticker, vals.name || vals.ticker)
message.success('已添加')
setAddOpen(false)
form.resetFields()
fetch_()
} catch (e) {
message.error(e.message)
}
}
const handleDelete = async (ticker) => {
try {
await portfolioApi.removeFromWatchlist(ticker)
message.success('已移除')
fetch_()
} catch (e) {
message.error(e.message)
}
}
const columns = [
{ title: '代码', dataIndex: 'ticker', key: 'ticker', width: 120,
render: t => <span className="text-data">{t}</span> },
{ title: '名称', dataIndex: 'name', key: 'name', render: t => <span style={{ fontWeight: 500 }}>{t}</span> },
{ title: '添加日期', dataIndex: 'added_at', key: 'added_at', width: 120 },
{
title: '操作', key: 'action', width: 100,
render: (_, r) => (
<Popconfirm title="确认移除?" onConfirm={() => handleDelete(r.ticker)} okText="确认" cancelText="取消">
<Button size="small" danger icon={<DeleteOutlined />}>移除</Button>
</Popconfirm>
),
},
]
return (
<div>
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<div className="card-header">
<div className="card-title">自选股列表</div>
<Space>
<Button icon={<PlusOutlined />} type="primary" onClick={() => setAddOpen(true)}>添加</Button>
<Button icon={<SyncOutlined />} onClick={fetch_} loading={loading}>刷新</Button>
</Space>
</div>
</div>
<div className="card">
<Table columns={columns} dataSource={data} rowKey="ticker" loading={loading} pagination={false} size="middle" />
{data.length === 0 && !loading && (
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<div className="empty-state-title">暂无自选股</div>
<div className="empty-state-description">点击上方"添加"将股票加入自选</div>
</div>
)}
</div>
<Modal title="添加自选股" open={addOpen} onCancel={() => { setAddOpen(false); form.resetFields() }} footer={null}>
<Form form={form} layout="vertical" onFinish={handleAdd}>
<Form.Item name="ticker" label="股票代码" rules={[{ required: true, message: '请输入股票代码' }]}>
<Input placeholder="如 300750.SZ" />
</Form.Item>
<Form.Item name="name" label="名称(可选)">
<Input placeholder="如 宁德时代" />
</Form.Item>
<Button type="primary" htmlType="submit" block>添加</Button>
</Form>
</Modal>
</div>
)
}
// ============== Tab 2: Positions ==============
function PositionsTab() {
const [data, setData] = useState([])
const [accounts, setAccounts] = useState([DEFAULT_ACCOUNT])
const [account, setAccount] = useState(null)
const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false)
const [form] = Form.useForm()
const fetchPositions = useCallback(async () => {
setLoading(true)
try {
const [posRes, accRes] = await Promise.all([
portfolioApi.getPositions(account),
portfolioApi.getAccounts(),
])
setData(posRes.positions || [])
setAccounts(accRes.accounts || [DEFAULT_ACCOUNT])
} catch {
message.error('加载失败')
} finally {
setLoading(false)
}
}, [account])
useEffect(() => { fetchPositions() }, [fetchPositions])
const handleAdd = async (vals) => {
try {
await portfolioApi.addPosition({ ...vals, account: account || DEFAULT_ACCOUNT })
message.success('已添加')
setAddOpen(false)
form.resetFields()
fetchPositions()
} catch (e) {
message.error(e.message)
}
}
const handleDelete = async (ticker, positionId) => {
try {
await portfolioApi.removePosition(ticker, positionId, account)
message.success('已移除')
fetchPositions()
} catch (e) {
message.error(e.message)
}
}
const handleExport = async () => {
try {
const blob = await portfolioApi.exportPositions(account)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'positions.csv'; a.click()
URL.revokeObjectURL(url)
} catch (e) {
message.error(e.message)
}
}
const totalPnl = useMemo(() => data.reduce((s, p) => s + (p.unrealized_pnl || 0), 0), [data])
const columns = [
{ title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
render: t => <span className="text-data">{t}</span> },
{ title: '账户', dataIndex: 'account', key: 'account', width: 100 },
{ title: '数量', dataIndex: 'shares', key: 'shares', align: 'right', width: 80,
render: v => <span className="text-data">{v}</span> },
{ title: '成本价', dataIndex: 'cost_price', key: 'cost_price', align: 'right', width: 90,
render: v => <span className="text-data">{formatMoney(v)}</span> },
{ title: '现价', dataIndex: 'current_price', key: 'current_price', align: 'right', width: 90,
render: v => <span className="text-data">{formatMoney(v)}</span> },
{
title: '浮亏浮盈',
key: 'pnl',
align: 'right',
width: 110,
render: (_, r) => {
const pnl = r.unrealized_pnl
const pct = r.unrealized_pnl_pct
const color = pnl == null ? undefined : pnl >= 0 ? 'var(--color-buy)' : 'var(--color-sell)'
return (
<span className="text-data" style={{ color }}>
{pnl == null ? '—' : `${pnl >= 0 ? '+' : ''}${formatMoney(pnl)}`}
<br />
<span style={{ fontSize: 11 }}>{pct == null ? '' : formatPct(pct)}</span>
</span>
)
},
},
{
title: '买入日期',
dataIndex: 'purchase_date',
key: 'purchase_date',
width: 100,
},
{
title: '操作', key: 'action', width: 80,
render: (_, r) => (
<Popconfirm title="确认平仓?" onConfirm={() => handleDelete(r.ticker, r.position_id)} okText="确认" cancelText="取消">
<Button size="small" danger icon={<DeleteOutlined />}>平仓</Button>
</Popconfirm>
),
},
]
return (
<div>
<Row gutter={16} style={{ marginBottom: 'var(--space-4)' }}>
<Col xs={24} sm={12}>
<div className="card">
<div className="text-caption">账户</div>
<Select
value={account || '全部'}
onChange={v => setAccount(v === '全部' ? null : v)}
style={{ width: '100%' }}
options={[{ value: '全部', label: '全部账户' }, ...accounts.map(a => ({ value: a, label: a }))]}
/>
</div>
</Col>
<Col xs={24} sm={12}>
<div className="card">
<div className="text-caption">总浮亏浮盈</div>
<div className="text-data" style={{ fontSize: 28, fontWeight: 600, color: totalPnl >= 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{formatMoney(totalPnl)}
</div>
</div>
</Col>
</Row>
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<div className="card-header">
<div className="card-title">持仓记录</div>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button icon={<PlusOutlined />} type="primary" onClick={() => setAddOpen(true)}>添加持仓</Button>
<Button icon={<SyncOutlined />} onClick={fetchPositions} loading={loading}>刷新</Button>
</Space>
</div>
</div>
<div className="card">
<Table columns={columns} dataSource={data} rowKey="position_id" loading={loading} pagination={false} size="middle" scroll={{ x: 700 }} />
{data.length === 0 && !loading && (
<div className="empty-state">
<AccountBookOutlined style={{ fontSize: 40, color: 'var(--text-muted)' }} />
<div className="empty-state-title">暂无持仓</div>
<div className="empty-state-description">点击"添加持仓"录入您的股票仓位</div>
</div>
)}
</div>
<Modal title="添加持仓" open={addOpen} onCancel={() => { setAddOpen(false); form.resetFields() }} footer={null}>
<Form form={form} layout="vertical" onFinish={handleAdd}>
<Form.Item name="ticker" label="股票代码" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="300750.SZ" />
</Form.Item>
<Form.Item name="shares" label="数量" rules={[{ required: true, message: '请输入' }]}>
<Input type="number" placeholder="100" />
</Form.Item>
<Form.Item name="cost_price" label="成本价" rules={[{ required: true, message: '请输入' }]}>
<Input type="number" placeholder="180.50" />
</Form.Item>
<Form.Item name="purchase_date" label="买入日期">
<Input placeholder="2026-01-15" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea placeholder="可选备注" />
</Form.Item>
<Button type="primary" htmlType="submit" block>添加</Button>
</Form>
</Modal>
</div>
)
}
// ============== Tab 3: Recommendations ==============
function RecommendationsTab() {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
const [analyzing, setAnalyzing] = useState(false)
const [taskId, setTaskId] = useState(null)
const [wsConnected, setWsConnected] = useState(false)
const [progress, setProgress] = useState(null)
const [selectedDate, setSelectedDate] = useState(null)
const [dates, setDates] = useState([])
const wsRef = useRef(null)
const fetchRecs = useCallback(async (date) => {
setLoading(true)
try {
const res = await portfolioApi.getRecommendations(date)
setData(res.recommendations || [])
if (!date) {
const d = [...new Set((res.recommendations || []).map(r => getDisplayDate(r)).filter(Boolean))].sort().reverse()
setDates(d)
}
} catch {
message.error('加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchRecs(selectedDate) }, [fetchRecs, selectedDate])
const connectWs = useCallback((tid) => {
if (wsRef.current) wsRef.current.close()
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const ws = new WebSocket(`${protocol}//${host}/ws/analysis/${tid}`)
ws.onopen = () => setWsConnected(true)
ws.onmessage = (e) => {
const d = JSON.parse(e.data)
if (d.type === 'progress') {
setProgress(d)
if (isCompletedLikeStatus(d.status) || d.status === 'failed') {
setAnalyzing(false)
setTaskId(null)
setProgress(null)
fetchRecs(selectedDate)
}
}
}
ws.onclose = () => setWsConnected(false)
wsRef.current = ws
}, [fetchRecs, selectedDate])
const handleAnalyze = async () => {
try {
const res = await portfolioApi.startAnalysis()
setTaskId(res.task_id)
setAnalyzing(true)
setProgress({ completed: 0, total: res.total, status: 'running' })
connectWs(res.task_id)
message.info('开始批量分析...')
} catch (e) {
message.error(e.message)
}
}
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
}
}, [])
const columns = [
{ title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
render: t => <span className="text-data">{t}</span> },
{ title: '名称', dataIndex: 'name', key: 'name', render: t => <span style={{ fontWeight: 500 }}>{t}</span> },
{
title: '决策', key: 'decision', width: 80,
render: (_, record) => <DecisionBadge decision={getDecision(record)} />,
},
{ title: '分析日期', key: 'analysis_date', width: 120, render: (_, record) => getDisplayDate(record) || '—' },
]
return (
<div>
{/* Analysis card */}
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<div className="card-header">
<div className="card-title">今日建议</div>
<Space>
{analyzing && progress && (
<span className="text-caption" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span className={`status-dot ${wsConnected ? 'connected' : 'error'}`} />
{progress.completed || 0} / {progress.total || 0}
</span>
)}
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleAnalyze}
loading={analyzing}
disabled={analyzing}
>
{analyzing ? '分析中...' : '生成今日建议'}
</Button>
</Space>
</div>
{analyzing && progress && (
<Progress
percent={Math.round(((progress.completed || 0) / (progress.total || 1)) * 100)}
status="active"
strokeColor="var(--accent)"
/>
)}
</div>
{/* Date filter */}
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<Select
allowClear
placeholder="筛选日期"
style={{ width: 200 }}
value={selectedDate}
onChange={setSelectedDate}
options={dates.map(d => ({ value: d, label: d }))}
/>
</div>
{/* Recommendations list */}
<div className="card">
<Table columns={columns} dataSource={data} rowKey="ticker" loading={loading} pagination={{ pageSize: 10 }} size="middle" />
{data.length === 0 && !loading && (
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<div className="empty-state-title">暂无建议</div>
<div className="empty-state-description">点击上方"生成今日建议"开始批量分析</div>
</div>
)}
</div>
</div>
)
}
// ============== Main ==============
export default function PortfolioPanel() {
const [activeTab, setActiveTab] = useState('watchlist')
const items = [
{ key: 'watchlist', label: '自选股', children: <WatchlistTab /> },
{ key: 'positions', label: '持仓', children: <PositionsTab /> },
{ key: 'recommendations', label: '今日建议', children: <RecommendationsTab /> },
]
return (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={items}
size="large"
/>
)
}