- 🐛 总资产计算修复:修复了多市场持仓汇总时货币转换错误的问题 - 🐛 浮动盈亏修复:修复了成本基数和市值货币单位不一致导致的错误 - 💹 持仓分析修复:确保所有持仓数据统一转换为 USD 后再汇总 - 📈 港股价格获取:改用腾讯行情接口(r_hk前缀)获取港股实时价格(小鹏汽车等) - 📈 A股价格获取:改用腾讯行情接口(sz/sh前缀)获取A股实时价格(海尔智家、中证现金流ETF等) - 📈 美股价格获取:改用腾讯行情接口(s_us前缀)获取美股实时价格 - 🔍 证券名称显示:搜索和选择证券时同时显示代码和中文名称 - 📋 证券数据库扩展:新增小鹏汽车(09868)、海尔智家(600690)、中证现金流ETF(159235)、Alphabet(GOOGL) - ✏️ 交易流水编辑:支持编辑已创建的交易记录 - 💰 持仓货币随市场:持仓明细和分析中各市场使用对应货币显示(港股用HKD、A股用CNY、美股用USD)
1503 lines
61 KiB
TypeScript
1503 lines
61 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useCallback } from 'react'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import {
|
||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend,
|
||
} from 'recharts'
|
||
import {
|
||
Wallet, TrendingUp, TrendingDown, Plus, ArrowUpRight, ArrowDownRight,
|
||
Bitcoin, Building2, Globe2, RefreshCw, DollarSign, Search, Check,
|
||
Download, Upload, Trash2, Edit,
|
||
} from 'lucide-react'
|
||
import {
|
||
fetchAccounts, fetchTransactions, fetchPositions,
|
||
fetchSecurities, createTransaction, deleteTransaction, updateTransaction, formatCurrency, formatPercent,
|
||
marketLabels, transactionTypeLabels
|
||
} from '@/lib/api'
|
||
import { Account, Position, Transaction, MarketType, TransactionType, Security } from '@/types'
|
||
import { toast } from 'sonner'
|
||
import {
|
||
exportTransactionsToCSV, exportPositionsToCSV, downloadCSV,
|
||
TRANSACTION_IMPORT_TEMPLATE, parseImportCSV, validateImportTransaction, ImportTransaction
|
||
} from '@/lib/import-export'
|
||
|
||
// 市场图标映射
|
||
const marketIcons: Record<MarketType, React.ReactNode> = {
|
||
US: <Globe2 className="h-4 w-4" />,
|
||
CN: <Building2 className="h-4 w-4" />,
|
||
HK: <Building2 className="h-4 w-4" />,
|
||
CRYPTO: <Bitcoin className="h-4 w-4" />,
|
||
}
|
||
|
||
// 市场颜色配置
|
||
const marketColors: Record<MarketType, string> = {
|
||
US: '#3b82f6',
|
||
CN: '#ef4444',
|
||
HK: '#f97316',
|
||
CRYPTO: '#eab308',
|
||
}
|
||
|
||
// 持仓分析数据结构
|
||
interface PositionAnalytics {
|
||
symbol: string
|
||
name: string
|
||
marketType: string
|
||
accountName: string
|
||
quantity: number
|
||
avgCost: number
|
||
currentPrice: number
|
||
change: number
|
||
changePercent: number
|
||
costBasis: number
|
||
marketValue: number
|
||
marketValueUSD: number
|
||
pnl: number
|
||
pnlPercent: number
|
||
pnlUSD: number
|
||
currency: string
|
||
isCrypto: boolean
|
||
}
|
||
|
||
// 分析汇总数据结构
|
||
interface AnalyticsSummary {
|
||
totalCostBasis: number
|
||
totalMarketValue: number
|
||
totalPnL: number
|
||
totalPnLPercent: number
|
||
positionCount: number
|
||
}
|
||
|
||
// 货币转换函数(以 USD 为基准)
|
||
const EXCHANGE_RATES: Record<string, number> = {
|
||
USD: 1,
|
||
CNY: 7.24, // 1 USD = 7.24 CNY
|
||
HKD: 7.78, // 1 USD = 7.78 HKD
|
||
}
|
||
|
||
function convertCurrency(amount: number, from: string, to: string): number {
|
||
if (from === to) return amount
|
||
// 先转换为 USD,再转换为目标货币
|
||
const usdAmount = amount / EXCHANGE_RATES[from]
|
||
return usdAmount * EXCHANGE_RATES[to]
|
||
}
|
||
|
||
// 主页面组件
|
||
export default function Dashboard() {
|
||
// 状态定义
|
||
const [accounts, setAccounts] = useState<Account[]>([])
|
||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||
const [positions, setPositions] = useState<Position[]>([])
|
||
const [securities, setSecurities] = useState<Security[]>([])
|
||
const [analytics, setAnalytics] = useState<{ prices: Record<string, any>; positions: PositionAnalytics[]; summary: AnalyticsSummary } | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [selectedAccountId, setSelectedAccountId] = useState<string>('')
|
||
const [showTxDialog, setShowTxDialog] = useState(false)
|
||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||
const [showImportDialog, setShowImportDialog] = useState(false)
|
||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||
const [positionToDelete, setPositionToDelete] = useState<PositionAnalytics | null>(null)
|
||
const [symbolSearch, setSymbolSearch] = useState('')
|
||
const [filteredSecurities, setFilteredSecurities] = useState<Security[]>([])
|
||
const [importFile, setImportFile] = useState<File | null>(null)
|
||
const [importData, setImportData] = useState<ImportTransaction[]>([])
|
||
|
||
// 显示货币状态(默认 CNY)
|
||
const [displayCurrency, setDisplayCurrency] = useState<'CNY' | 'USD' | 'HKD'>('CNY')
|
||
|
||
// 交易记录删除状态
|
||
const [transactionToDelete, setTransactionToDelete] = useState<Transaction | null>(null)
|
||
const [showDeleteTxDialog, setShowDeleteTxDialog] = useState(false)
|
||
|
||
// 交易记录编辑状态
|
||
const [transactionToEdit, setTransactionToEdit] = useState<Transaction | null>(null)
|
||
const [showEditTxDialog, setShowEditTxDialog] = useState(false)
|
||
const [editTxForm, setEditTxForm] = useState({
|
||
type: 'BUY' as TransactionType,
|
||
symbol: '',
|
||
quantity: '',
|
||
price: '',
|
||
amount: '',
|
||
fee: '0',
|
||
currency: 'USD',
|
||
notes: '',
|
||
executedAt: new Date().toISOString().slice(0, 16),
|
||
})
|
||
|
||
// 交易表单状态
|
||
const [txForm, setTxForm] = useState({
|
||
type: 'BUY' as TransactionType,
|
||
symbol: '',
|
||
quantity: '',
|
||
price: '',
|
||
amount: '', // 仅用于出金/入金
|
||
fee: '0',
|
||
currency: 'USD',
|
||
notes: '',
|
||
executedAt: new Date().toISOString().slice(0, 16),
|
||
})
|
||
|
||
// 获取当前选中的账户对象
|
||
const selectedAccount = accounts.find(a => a.id === selectedAccountId)
|
||
|
||
// 加载数据
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
setLoading(true)
|
||
// 并行加载所有数据
|
||
const [accountsData, transactionsData, positionsData, securitiesData, analyticsData] = await Promise.all([
|
||
fetchAccounts(),
|
||
fetchTransactions({ limit: 50 }),
|
||
fetchPositions(),
|
||
fetchSecurities(),
|
||
fetch('/api/dashboard/analytics').then(r => r.json()).catch(() => null),
|
||
])
|
||
|
||
setAccounts(accountsData)
|
||
setTransactions(transactionsData.data || [])
|
||
setPositions(positionsData)
|
||
setSecurities(securitiesData)
|
||
setAnalytics(analyticsData)
|
||
|
||
// 默认选中第一个账户
|
||
if (accountsData.length > 0 && !selectedAccountId) {
|
||
setSelectedAccountId(accountsData[0].id)
|
||
}
|
||
} catch (error) {
|
||
toast.error('加载数据失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [selectedAccountId])
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [loadData])
|
||
|
||
// 搜索证券(根据输入实时过滤)
|
||
useEffect(() => {
|
||
if (symbolSearch.length >= 1) {
|
||
const filtered = securities.filter(s =>
|
||
s.symbol.toLowerCase().includes(symbolSearch.toLowerCase()) ||
|
||
s.name.toLowerCase().includes(symbolSearch.toLowerCase())
|
||
).slice(0, 8)
|
||
setFilteredSecurities(filtered)
|
||
} else {
|
||
setFilteredSecurities([])
|
||
}
|
||
}, [symbolSearch, securities])
|
||
|
||
// 获取证券显示文本(代码+名称)
|
||
const getSecurityDisplayText = (symbol: string) => {
|
||
if (!symbol) return ''
|
||
const sec = securities.find(s => s.symbol === symbol)
|
||
return sec ? `${sec.symbol} ${sec.name}` : symbol
|
||
}
|
||
|
||
// 选择证券后自动填充价格和币种
|
||
const handleSelectSecurity = (symbol: string) => {
|
||
const price = analytics?.prices[symbol]?.price || 0
|
||
const sec = securities.find(s => s.symbol === symbol)
|
||
setTxForm(prev => ({
|
||
...prev,
|
||
symbol,
|
||
price: price > 0 ? price.toString() : prev.price,
|
||
currency: sec?.currency || prev.currency,
|
||
}))
|
||
setSymbolSearch('')
|
||
setFilteredSecurities([])
|
||
}
|
||
|
||
// 当数量或价格变化时,自动计算成交总额
|
||
const calculatedAmount = txForm.quantity && txForm.price
|
||
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
|
||
: ''
|
||
|
||
// 提交交易记录
|
||
const handleSubmitTx = async () => {
|
||
try {
|
||
if (!selectedAccountId) {
|
||
toast.error('请先选择账户')
|
||
return
|
||
}
|
||
|
||
const amount = txForm.quantity && txForm.price
|
||
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
|
||
: txForm.amount
|
||
|
||
await createTransaction({
|
||
accountId: selectedAccountId,
|
||
type: txForm.type,
|
||
symbol: txForm.symbol || undefined,
|
||
quantity: txForm.quantity ? parseFloat(txForm.quantity) : undefined,
|
||
price: txForm.price ? parseFloat(txForm.price) : undefined,
|
||
amount: parseFloat(amount || '0'),
|
||
fee: parseFloat(txForm.fee) || 0,
|
||
currency: txForm.currency,
|
||
notes: txForm.notes || undefined,
|
||
executedAt: txForm.executedAt,
|
||
})
|
||
toast.success('交易记录成功')
|
||
setShowTxDialog(false)
|
||
setShowConfirmDialog(false)
|
||
resetTxForm()
|
||
loadData()
|
||
} catch (error) {
|
||
toast.error('创建交易失败')
|
||
}
|
||
}
|
||
|
||
// 删除持仓
|
||
const handleDeletePosition = async () => {
|
||
if (!positionToDelete) return
|
||
|
||
try {
|
||
// 通过卖出全部来删除持仓
|
||
const position = positions.find(p => p.symbol === positionToDelete.symbol)
|
||
if (!position) {
|
||
toast.error('未找到持仓记录')
|
||
return
|
||
}
|
||
|
||
await createTransaction({
|
||
accountId: selectedAccountId || position.accountId,
|
||
type: 'SELL',
|
||
symbol: positionToDelete.symbol,
|
||
quantity: parseFloat(positionToDelete.quantity.toString()),
|
||
price: parseFloat(positionToDelete.currentPrice.toString()),
|
||
amount: parseFloat((positionToDelete.quantity * positionToDelete.currentPrice).toFixed(2)),
|
||
fee: 0,
|
||
currency: positionToDelete.currency,
|
||
notes: '删除持仓',
|
||
executedAt: new Date().toISOString(),
|
||
})
|
||
|
||
toast.success('持仓已删除')
|
||
setShowDeleteDialog(false)
|
||
setPositionToDelete(null)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.error('删除持仓失败')
|
||
}
|
||
}
|
||
|
||
// 打开删除确认对话框
|
||
const openDeleteDialog = (pos: PositionAnalytics) => {
|
||
setPositionToDelete(pos)
|
||
setShowDeleteDialog(true)
|
||
}
|
||
|
||
// 打开删除交易记录对话框
|
||
const openDeleteTxDialog = (tx: Transaction) => {
|
||
setTransactionToDelete(tx)
|
||
setShowDeleteTxDialog(true)
|
||
}
|
||
|
||
// 删除交易记录
|
||
const handleDeleteTransaction = async () => {
|
||
if (!transactionToDelete) return
|
||
|
||
try {
|
||
await deleteTransaction(transactionToDelete.id)
|
||
toast.success('交易记录已删除')
|
||
setShowDeleteTxDialog(false)
|
||
setTransactionToDelete(null)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.error('删除交易记录失败')
|
||
}
|
||
}
|
||
|
||
// 打开编辑交易记录对话框
|
||
const openEditTxDialog = (tx: Transaction) => {
|
||
setTransactionToEdit(tx)
|
||
setEditTxForm({
|
||
type: tx.type,
|
||
symbol: tx.symbol || '',
|
||
quantity: tx.quantity ? parseFloat(tx.quantity.toString()).toString() : '',
|
||
price: tx.price ? parseFloat(tx.price.toString()).toString() : '',
|
||
amount: parseFloat(tx.amount.toString()).toString(),
|
||
fee: parseFloat(tx.fee.toString()).toString(),
|
||
currency: tx.currency,
|
||
notes: tx.notes || '',
|
||
executedAt: new Date(tx.executedAt).toISOString().slice(0, 16),
|
||
})
|
||
setShowEditTxDialog(true)
|
||
}
|
||
|
||
// 提交编辑交易记录
|
||
const handleEditTransaction = async () => {
|
||
if (!transactionToEdit) return
|
||
|
||
try {
|
||
const amount = editTxForm.quantity && editTxForm.price
|
||
? (parseFloat(editTxForm.quantity) * parseFloat(editTxForm.price)).toFixed(2)
|
||
: editTxForm.amount
|
||
|
||
await updateTransaction(transactionToEdit.id, {
|
||
type: editTxForm.type,
|
||
symbol: editTxForm.symbol || undefined,
|
||
quantity: editTxForm.quantity ? parseFloat(editTxForm.quantity) : undefined,
|
||
price: editTxForm.price ? parseFloat(editTxForm.price) : undefined,
|
||
amount: parseFloat(amount || '0'),
|
||
fee: parseFloat(editTxForm.fee) || 0,
|
||
currency: editTxForm.currency,
|
||
notes: editTxForm.notes || undefined,
|
||
executedAt: editTxForm.executedAt,
|
||
})
|
||
toast.success('交易记录已更新')
|
||
setShowEditTxDialog(false)
|
||
setTransactionToEdit(null)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.error('更新交易记录失败')
|
||
}
|
||
}
|
||
|
||
// 重置交易表单
|
||
const resetTxForm = () => {
|
||
setTxForm({
|
||
type: 'BUY',
|
||
symbol: '',
|
||
quantity: '',
|
||
price: '',
|
||
amount: '',
|
||
fee: '0',
|
||
currency: selectedAccount?.baseCurrency || 'USD',
|
||
notes: '',
|
||
executedAt: new Date().toISOString().slice(0, 16),
|
||
})
|
||
}
|
||
|
||
// 打开交易对话框
|
||
const openTxDialog = () => {
|
||
resetTxForm()
|
||
setShowTxDialog(true)
|
||
}
|
||
|
||
// 获取确认对话框显示信息
|
||
const getConfirmInfo = () => {
|
||
const typeLabel = transactionTypeLabels[txForm.type]
|
||
const symbolLabel = txForm.symbol ? `${txForm.symbol} ` : ''
|
||
const qtyLabel = txForm.quantity ? `${txForm.quantity}股 ` : ''
|
||
const priceLabel = txForm.price ? `@ ${formatCurrency(parseFloat(txForm.price), txForm.currency)}` : ''
|
||
const total = txForm.quantity && txForm.price
|
||
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
|
||
: txForm.amount
|
||
|
||
return {
|
||
account: selectedAccount?.name || '未选择',
|
||
market: selectedAccount ? marketLabels[selectedAccount.marketType] : '',
|
||
action: `${typeLabel} ${symbolLabel}${qtyLabel}${priceLabel}`,
|
||
total: total ? `合计 ${formatCurrency(parseFloat(total), txForm.currency)}` : '',
|
||
fee: txForm.fee && parseFloat(txForm.fee) > 0 ? `手续费: ${formatCurrency(parseFloat(txForm.fee), txForm.currency)}` : '',
|
||
time: new Date(txForm.executedAt).toLocaleString('zh-CN'),
|
||
}
|
||
}
|
||
|
||
// 导出交易记录为 CSV
|
||
const handleExportTransactions = () => {
|
||
const csv = exportTransactionsToCSV(transactions)
|
||
const date = new Date().toISOString().slice(0, 10)
|
||
downloadCSV(csv, `transactions_${date}.csv`)
|
||
toast.success('交易记录已导出')
|
||
}
|
||
|
||
// 导出持仓记录为 CSV
|
||
const handleExportPositions = () => {
|
||
const csv = exportPositionsToCSV(positions.map(p => ({
|
||
...p,
|
||
name: analytics?.positions?.find(ap => ap.symbol === p.symbol)?.name || p.symbol,
|
||
currentPrice: analytics?.prices[p.symbol]?.price || parseFloat(p.averageCost),
|
||
value: parseFloat(p.quantity) * (analytics?.prices[p.symbol]?.price || parseFloat(p.averageCost)),
|
||
valueInUSD: parseFloat(p.quantity) * (analytics?.prices[p.symbol]?.price || parseFloat(p.averageCost)),
|
||
pnl: 0,
|
||
pnlPercent: 0,
|
||
})))
|
||
const date = new Date().toISOString().slice(0, 10)
|
||
downloadCSV(csv, `positions_${date}.csv`)
|
||
toast.success('持仓记录已导出')
|
||
}
|
||
|
||
// 下载导入模板 CSV
|
||
const handleDownloadTemplate = () => {
|
||
downloadCSV(TRANSACTION_IMPORT_TEMPLATE, 'transaction_import_template.csv')
|
||
}
|
||
|
||
// 处理文件导入
|
||
const handleFileImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
|
||
setImportFile(file)
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
const content = event.target?.result as string
|
||
const { headers, rows } = parseImportCSV(content)
|
||
// 验证每一行数据
|
||
const validated = rows.map(row => validateImportTransaction(row, headers))
|
||
setImportData(validated)
|
||
}
|
||
reader.readAsText(file)
|
||
}
|
||
|
||
// 执行批量导入
|
||
const handleExecuteImport = async () => {
|
||
if (!selectedAccountId) {
|
||
toast.error('请先选择导入目标账户')
|
||
return
|
||
}
|
||
|
||
const validTxs = importData.filter(tx => tx.errors.length === 0)
|
||
if (validTxs.length === 0) {
|
||
toast.error('没有有效的交易记录')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/import/transactions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
transactions: validTxs.map(tx => ({
|
||
type: tx.type,
|
||
symbol: tx.symbol || null,
|
||
quantity: tx.quantity || null,
|
||
price: tx.price || null,
|
||
amount: tx.amount,
|
||
fee: tx.fee || '0',
|
||
currency: tx.currency,
|
||
notes: tx.notes || null,
|
||
executedAt: tx.executedAt,
|
||
})),
|
||
accountId: selectedAccountId,
|
||
}),
|
||
})
|
||
|
||
const result = await response.json()
|
||
toast.success(`导入完成: ${result.success} 成功, ${result.failed} 失败`)
|
||
setShowImportDialog(false)
|
||
setImportData([])
|
||
setImportFile(null)
|
||
loadData()
|
||
} catch (error) {
|
||
toast.error('导入失败')
|
||
}
|
||
}
|
||
|
||
// 市场分布数据(用于饼图)
|
||
const marketDistribution = analytics?.summary ? [
|
||
{ name: '美股', value: analytics.summary.totalMarketValue * 0.6, color: marketColors.US },
|
||
{ name: 'A股', value: analytics.summary.totalMarketValue * 0.2, color: marketColors.CN },
|
||
{ name: '港股', value: analytics.summary.totalMarketValue * 0.15, color: marketColors.HK },
|
||
{ name: '加密', value: analytics.summary.totalMarketValue * 0.05, color: marketColors.CRYPTO },
|
||
].filter(item => item.value > 0) : []
|
||
|
||
// 加载中状态
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
{/* 顶部导航栏 */}
|
||
<header className="border-b bg-card/50 backdrop-blur sticky top-0 z-50">
|
||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Wallet className="h-6 w-6 text-primary" />
|
||
<h1 className="text-xl font-bold">投资持仓管理</h1>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* 显示货币选择 */}
|
||
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as 'CNY' | 'USD' | 'HKD')}>
|
||
<SelectTrigger className="w-[100px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="CNY">CNY ¥</SelectItem>
|
||
<SelectItem value="USD">USD $</SelectItem>
|
||
<SelectItem value="HKD">HKD $</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{/* 账户选择下拉框 */}
|
||
<Select value={selectedAccountId} onValueChange={(v) => v && setSelectedAccountId(v)}>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue placeholder="选择账户">
|
||
{selectedAccount ? (
|
||
<div className="flex items-center gap-2">
|
||
{marketIcons[selectedAccount.marketType]}
|
||
<span>{selectedAccount.name}</span>
|
||
</div>
|
||
) : '选择账户'}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{accounts.map((acc) => (
|
||
<SelectItem key={acc.id} value={acc.id}>
|
||
<div className="flex items-center gap-2">
|
||
{marketIcons[acc.marketType]}
|
||
<span>{acc.name}</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{/* 导入按钮 */}
|
||
<Button variant="outline" size="sm" onClick={() => setShowImportDialog(true)}>
|
||
<Upload className="h-4 w-4 mr-2" />
|
||
导入
|
||
</Button>
|
||
{/* 导出按钮 */}
|
||
<Button variant="outline" size="sm" onClick={handleExportPositions}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
导出
|
||
</Button>
|
||
{/* 记录交易按钮 */}
|
||
<Button onClick={openTxDialog}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
记录交易
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="container mx-auto px-4 py-6 space-y-6">
|
||
{/* 资产概览卡片 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
{/* 总资产卡片 */}
|
||
<Card className="bg-gradient-to-br from-blue-600 to-blue-700 text-white border-0">
|
||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
<CardTitle className="text-sm font-medium opacity-90">总资产 ({displayCurrency})</CardTitle>
|
||
<Wallet className="h-4 w-4 opacity-80" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalMarketValue || 0, 'USD', displayCurrency), displayCurrency)}</div>
|
||
<p className="text-xs opacity-80 mt-1">
|
||
成本 {formatCurrency(convertCurrency(analytics?.summary?.totalCostBasis || 0, 'USD', displayCurrency), displayCurrency)}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 浮动盈亏卡片 */}
|
||
<Card className="bg-gradient-to-br from-emerald-600 to-emerald-700 text-white border-0">
|
||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
<CardTitle className="text-sm font-medium opacity-90">浮动盈亏</CardTitle>
|
||
<TrendingUp className="h-4 w-4 opacity-80" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalPnL || 0, 'USD', displayCurrency), displayCurrency)}</div>
|
||
<p className={`text-xs mt-1 flex items-center gap-1 ${(analytics?.summary?.totalPnL || 0) >= 0 ? 'text-emerald-200' : 'text-red-200'}`}>
|
||
{(analytics?.summary?.totalPnL || 0) >= 0 ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
|
||
{formatPercent(analytics?.summary?.totalPnLPercent || 0)}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 持仓市值卡片 */}
|
||
<Card className="bg-gradient-to-br from-purple-600 to-purple-700 text-white border-0">
|
||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
<CardTitle className="text-sm font-medium opacity-90">持仓市值</CardTitle>
|
||
<DollarSign className="h-4 w-4 opacity-80" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalMarketValue || 0, 'USD', displayCurrency), displayCurrency)}</div>
|
||
<p className="text-xs opacity-80 mt-1">{analytics?.summary?.positionCount || 0} 个持仓</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 账户数量卡片 */}
|
||
<Card className="bg-gradient-to-br from-orange-600 to-orange-700 text-white border-0">
|
||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
<CardTitle className="text-sm font-medium opacity-90">账户数量</CardTitle>
|
||
<Building2 className="h-4 w-4 opacity-80" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">{accounts.length}</div>
|
||
<p className="text-xs opacity-80 mt-1">分布在 {accounts.length} 个市场</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 图表区域 */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* 市场分布饼图 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">资产配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{marketDistribution.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height={240}>
|
||
<PieChart>
|
||
<Pie
|
||
data={marketDistribution}
|
||
cx="50%"
|
||
cy="50%"
|
||
innerRadius={60}
|
||
outerRadius={100}
|
||
paddingAngle={2}
|
||
dataKey="value"
|
||
>
|
||
{marketDistribution.map((entry, index) => (
|
||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip
|
||
formatter={(value) => formatCurrency(value as number)}
|
||
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)' }}
|
||
/>
|
||
<Legend />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
) : (
|
||
<div className="h-[240px] flex items-center justify-center text-muted-foreground">
|
||
暂无数据
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 持仓分析卡片 */}
|
||
<Card className="lg:col-span-2">
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">持仓分析</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
{analytics?.positions?.slice(0, 4).map((pos) => (
|
||
<div key={pos.symbol} className="p-4 rounded-lg bg-muted/50">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
{marketIcons[pos.marketType as MarketType]}
|
||
<span className="font-medium">{pos.symbol}</span>
|
||
<span className="text-xs text-muted-foreground">{pos.currency}</span>
|
||
</div>
|
||
<div className="text-lg font-bold">{formatCurrency(pos.marketValue, pos.currency)}</div>
|
||
<Separator className="my-2" />
|
||
<div className={`text-sm flex items-center gap-1 ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||
{formatCurrency(Math.abs(pos.pnl), pos.currency)} ({formatPercent(pos.pnlPercent)})
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 标签页区域:持仓明细、交易流水、分析 */}
|
||
<Tabs defaultValue="positions" className="space-y-4">
|
||
<TabsList>
|
||
<TabsTrigger value="positions">持仓明细</TabsTrigger>
|
||
<TabsTrigger value="transactions">交易流水</TabsTrigger>
|
||
<TabsTrigger value="analytics">分析</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* 持仓明细标签页 */}
|
||
<TabsContent value="positions">
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>证券</TableHead>
|
||
<TableHead>市场</TableHead>
|
||
<TableHead className="text-right">数量</TableHead>
|
||
<TableHead className="text-right">成本价</TableHead>
|
||
<TableHead className="text-right">当前价</TableHead>
|
||
<TableHead className="text-right">市值</TableHead>
|
||
<TableHead className="text-right">盈亏</TableHead>
|
||
<TableHead className="w-[50px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{analytics?.positions && analytics.positions.length > 0 ? analytics.positions?.map((pos) => (
|
||
<TableRow key={pos.symbol}>
|
||
<TableCell>
|
||
<div className="font-medium">{pos.symbol}</div>
|
||
<div className="text-xs text-muted-foreground">{pos.name}</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="outline" className="gap-1">
|
||
{marketIcons[pos.marketType as MarketType]}
|
||
{marketLabels[pos.marketType as MarketType]}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{pos.quantity.toFixed(pos.isCrypto ? 6 : 0)}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{formatCurrency(pos.avgCost, pos.currency)}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
<div className="flex items-center justify-end gap-2">
|
||
{formatCurrency(pos.currentPrice, pos.currency)}
|
||
{pos.change !== 0 && (
|
||
<span className={`text-xs ${pos.change >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||
{pos.change >= 0 ? '+' : ''}{pos.changePercent.toFixed(2)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{formatCurrency(pos.marketValue, pos.currency)}
|
||
</TableCell>
|
||
<TableCell className={`text-right font-mono ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||
<div className="flex items-center justify-end gap-1">
|
||
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||
{formatCurrency(Math.abs(pos.pnl))}
|
||
<span className="text-xs">({formatPercent(pos.pnlPercent)})</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Button variant="ghost" size="sm" onClick={() => openDeleteDialog(pos)}>
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
)) : (
|
||
<TableRow>
|
||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||
暂无持仓记录
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 交易流水标签页 */}
|
||
<TabsContent value="transactions">
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<CardTitle>交易流水</CardTitle>
|
||
<Button variant="outline" size="sm" onClick={handleExportTransactions}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
导出 CSV
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>时间</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>证券</TableHead>
|
||
<TableHead className="text-right">数量</TableHead>
|
||
<TableHead className="text-right">价格</TableHead>
|
||
<TableHead className="text-right">金额</TableHead>
|
||
<TableHead>账户</TableHead>
|
||
<TableHead className="w-[50px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{transactions.length > 0 ? transactions.map((tx) => (
|
||
<TableRow key={tx.id}>
|
||
<TableCell className="text-sm">
|
||
{new Date(tx.executedAt).toLocaleDateString('zh-CN')}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge
|
||
variant={
|
||
tx.type === 'BUY' || tx.type === 'DEPOSIT' || tx.type === 'DIVIDEND'
|
||
? 'default'
|
||
: 'secondary'
|
||
}
|
||
>
|
||
{transactionTypeLabels[tx.type]}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="font-mono">{tx.symbol || '-'}</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{tx.quantity ? parseFloat(tx.quantity).toFixed(4) : '-'}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{tx.price ? formatCurrency(parseFloat(tx.price), tx.currency) : '-'}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono font-medium">
|
||
{formatCurrency(parseFloat(tx.amount), tx.currency)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-1">
|
||
{marketIcons[tx.account?.marketType || 'US']}
|
||
<span className="text-sm">{tx.account?.name}</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-1">
|
||
<Button variant="ghost" size="sm" onClick={() => openEditTxDialog(tx)}>
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => openDeleteTxDialog(tx)}>
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
)) : (
|
||
<TableRow>
|
||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||
暂无交易记录
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 分析标签页 */}
|
||
<TabsContent value="analytics">
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* 资产分布条形图 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">资产分布</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{analytics?.positions?.map((pos) => {
|
||
const percent = analytics?.summary?.totalMarketValue && analytics.summary.totalMarketValue > 0
|
||
? (pos.marketValueUSD / analytics.summary.totalMarketValue) * 100
|
||
: 0
|
||
return (
|
||
<div key={pos.symbol} className="space-y-2">
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center gap-2">
|
||
{marketIcons[pos.marketType as MarketType]}
|
||
<span className="font-medium">{pos.symbol}</span>
|
||
<span className="text-xs text-muted-foreground">{pos.currency}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted-foreground">{percent.toFixed(1)}%</span>
|
||
<span className="font-mono">{formatCurrency(pos.marketValue, pos.currency)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full transition-all"
|
||
style={{
|
||
width: `${percent}%`,
|
||
backgroundColor: marketColors[pos.marketType as MarketType]
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 盈亏排行榜 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">盈亏排行</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{analytics?.positions
|
||
?.sort((a, b) => b.pnlUSD - a.pnlUSD)
|
||
.map((pos, index) => (
|
||
<div key={pos.symbol} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-lg font-bold text-muted-foreground">#{index + 1}</span>
|
||
<div>
|
||
<div className="font-medium">{pos.symbol}</div>
|
||
<div className="text-xs text-muted-foreground">{pos.name}</div>
|
||
</div>
|
||
</div>
|
||
<div className={`text-right font-mono ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||
<div className="flex items-center gap-1 justify-end">
|
||
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||
{formatCurrency(Math.abs(pos.pnl), pos.currency)}
|
||
</div>
|
||
<div className="text-xs">{formatPercent(pos.pnlPercent)}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</main>
|
||
|
||
{/* 记录交易对话框 */}
|
||
<Dialog open={showTxDialog} onOpenChange={setShowTxDialog}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>记录交易</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{/* 账户选择 */}
|
||
<div className="space-y-2">
|
||
<Label>账户</Label>
|
||
<Select value={selectedAccountId} onValueChange={(v) => {
|
||
if (v) {
|
||
setSelectedAccountId(v)
|
||
const selected = accounts.find(a => a.id === v)
|
||
if (selected) {
|
||
setTxForm(prev => ({ ...prev, currency: selected.baseCurrency }))
|
||
}
|
||
}
|
||
}}>
|
||
<SelectTrigger>
|
||
<SelectValue>
|
||
{selectedAccount ? selectedAccount.name : '选择账户'}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{accounts.map((acc) => (
|
||
<SelectItem key={acc.id} value={acc.id}>
|
||
{acc.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* 交易类型选择 */}
|
||
<div className="space-y-2">
|
||
<Label>交易类型</Label>
|
||
<Select value={txForm.type} onValueChange={(v) => setTxForm({ ...txForm, type: v as TransactionType })}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(['BUY', 'SELL', 'DEPOSIT', 'WITHDRAW', 'DIVIDEND'] as TransactionType[]).map((t) => (
|
||
<SelectItem key={t} value={t}>
|
||
{transactionTypeLabels[t]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 证券代码搜索(买入/卖出/分红时显示) */}
|
||
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
|
||
<div className="space-y-2">
|
||
<Label>证券代码</Label>
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
className="pl-9"
|
||
placeholder="搜索证券代码或名称"
|
||
value={symbolSearch || getSecurityDisplayText(txForm.symbol)}
|
||
onChange={(e) => {
|
||
setSymbolSearch(e.target.value)
|
||
setTxForm({ ...txForm, symbol: e.target.value.toUpperCase() })
|
||
}}
|
||
/>
|
||
{/* 搜索结果下拉列表 */}
|
||
{filteredSecurities.length > 0 && (
|
||
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-48 overflow-auto">
|
||
{filteredSecurities.map((sec) => (
|
||
<button
|
||
key={sec.id}
|
||
className="w-full px-3 py-2 text-left hover:bg-accent flex items-center justify-between"
|
||
onClick={() => handleSelectSecurity(sec.symbol)}
|
||
>
|
||
<div>
|
||
<span className="font-medium">{sec.symbol}</span>
|
||
<span className="text-muted-foreground ml-2">{sec.name}</span>
|
||
</div>
|
||
<Badge variant="outline" className="text-xs">
|
||
{marketLabels[sec.market]}
|
||
</Badge>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 数量和价格输入(买入/卖出/分红时显示) */}
|
||
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>数量</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0"
|
||
value={txForm.quantity}
|
||
onChange={(e) => setTxForm({ ...txForm, quantity: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>价格</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0.00"
|
||
value={txForm.price}
|
||
onChange={(e) => setTxForm({ ...txForm, price: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 成交总额(自动计算) */}
|
||
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
|
||
<div className="space-y-2">
|
||
<Label>成交总额</Label>
|
||
<div className="p-3 bg-muted rounded-lg">
|
||
<span className="text-2xl font-bold">
|
||
{calculatedAmount ? formatCurrency(parseFloat(calculatedAmount), txForm.currency) : '—'}
|
||
</span>
|
||
{txForm.quantity && txForm.price && (
|
||
<span className="text-sm text-muted-foreground ml-2">
|
||
({txForm.quantity} × {formatCurrency(parseFloat(txForm.price), txForm.currency)})
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 入金/出金金额输入 */}
|
||
{['DEPOSIT', 'WITHDRAW'].includes(txForm.type) && (
|
||
<div className="space-y-2">
|
||
<Label>金额</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0.00"
|
||
value={txForm.amount}
|
||
onChange={(e) => setTxForm({ ...txForm, amount: e.target.value })}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 手续费输入 */}
|
||
<div className="space-y-2">
|
||
<Label>手续费</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0.00"
|
||
value={txForm.fee}
|
||
onChange={(e) => setTxForm({ ...txForm, fee: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 交易时间 */}
|
||
<div className="space-y-2">
|
||
<Label>交易时间</Label>
|
||
<Input
|
||
type="datetime-local"
|
||
value={txForm.executedAt}
|
||
onChange={(e) => setTxForm({ ...txForm, executedAt: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 备注 */}
|
||
<div className="space-y-2">
|
||
<Label>备注</Label>
|
||
<Input
|
||
placeholder="可选备注"
|
||
value={txForm.notes}
|
||
onChange={(e) => setTxForm({ ...txForm, notes: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 提交按钮 */}
|
||
<Button className="w-full" onClick={() => {
|
||
if (!selectedAccountId) {
|
||
toast.error('请先选择账户')
|
||
return
|
||
}
|
||
if (['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (!txForm.quantity || !txForm.price)) {
|
||
toast.error('请输入数量和价格')
|
||
return
|
||
}
|
||
if (['DEPOSIT', 'WITHDRAW'].includes(txForm.type) && !txForm.amount) {
|
||
toast.error('请输入金额')
|
||
return
|
||
}
|
||
setShowConfirmDialog(true)
|
||
}}>
|
||
确认记录
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 交易确认对话框 */}
|
||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||
<DialogContent className="sm:max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>确认交易信息</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="py-4 space-y-3">
|
||
{(() => {
|
||
const info = getConfirmInfo()
|
||
return (
|
||
<>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className="text-muted-foreground">账户:</span>
|
||
<span className="font-medium">{info.account}</span>
|
||
<Badge variant="outline" className="text-xs">{info.market}</Badge>
|
||
</div>
|
||
<Separator />
|
||
<div className="text-center py-2">
|
||
<div className="text-2xl font-bold">{info.action}</div>
|
||
</div>
|
||
<div className="flex items-center justify-between text-sm bg-muted/50 p-3 rounded-lg">
|
||
<span className="text-muted-foreground">成交总额</span>
|
||
<span className="text-xl font-bold">{info.total}</span>
|
||
</div>
|
||
{info.fee && (
|
||
<div className="text-sm text-muted-foreground text-center">
|
||
{info.fee}
|
||
</div>
|
||
)}
|
||
<Separator />
|
||
<div className="text-xs text-muted-foreground text-center">
|
||
交易时间: {info.time}
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
<DialogFooter className="gap-2 sm:gap-0">
|
||
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleSubmitTx}>
|
||
<Check className="h-4 w-4 mr-1" />
|
||
确认
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 编辑交易记录对话框 */}
|
||
<Dialog open={showEditTxDialog} onOpenChange={setShowEditTxDialog}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>编辑交易记录</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{/* 交易类型选择 */}
|
||
<div className="space-y-2">
|
||
<Label>交易类型</Label>
|
||
<Select value={editTxForm.type} onValueChange={(v) => setEditTxForm({ ...editTxForm, type: v as TransactionType })}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(['BUY', 'SELL', 'DEPOSIT', 'WITHDRAW', 'DIVIDEND'] as TransactionType[]).map((t) => (
|
||
<SelectItem key={t} value={t}>
|
||
{transactionTypeLabels[t]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* 交易时间 */}
|
||
<div className="space-y-2">
|
||
<Label>交易时间</Label>
|
||
<Input
|
||
type="datetime-local"
|
||
value={editTxForm.executedAt}
|
||
onChange={(e) => setEditTxForm({ ...editTxForm, executedAt: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 证券代码(买入/卖出/分红时显示) */}
|
||
{['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && (
|
||
<div className="space-y-2">
|
||
<Label>证券代码</Label>
|
||
<Input
|
||
placeholder="输入证券代码"
|
||
value={editTxForm.symbol}
|
||
onChange={(e) => setEditTxForm({ ...editTxForm, symbol: e.target.value.toUpperCase() })}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 数量和价格输入(买入/卖出/分红时显示) */}
|
||
{['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>数量</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0"
|
||
value={editTxForm.quantity}
|
||
onChange={(e) => setEditTxForm({ ...editTxForm, quantity: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>价格</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0.00"
|
||
value={editTxForm.price}
|
||
onChange={(e) => setEditTxForm({ ...editTxForm, price: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 金额输入(入金/出金时显示) */}
|
||
{['DEPOSIT', 'WITHDRAW'].includes(editTxForm.type) && (
|
||
<div className="space-y-2">
|
||
<Label>金额</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0.00"
|
||
value={editTxForm.amount}
|
||
onChange={(e) => setEditTxForm({ ...editTxForm, amount: e.target.value })}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 手续费输入 */}
|
||
<div className="space-y-2">
|
||
<Label>手续费</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
placeholder="0.00"
|
||
value={editTxForm.fee}
|
||
onChange={(e) => setEditTxForm({ ...editTxForm, fee: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 备注 */}
|
||
<div className="space-y-2">
|
||
<Label>备注</Label>
|
||
<Input
|
||
placeholder="可选备注"
|
||
value={editTxForm.notes}
|
||
onChange={(e) => setEditTxForm({ ...editTxForm, notes: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 提交按钮 */}
|
||
<Button className="w-full" onClick={handleEditTransaction}>
|
||
保存修改
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 删除交易记录确认对话框 */}
|
||
<Dialog open={showDeleteTxDialog} onOpenChange={setShowDeleteTxDialog}>
|
||
<DialogContent className="sm:max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>确认删除交易记录</DialogTitle>
|
||
</DialogHeader>
|
||
{transactionToDelete && (
|
||
<div className="py-4 space-y-3">
|
||
<div className="bg-muted/50 p-3 rounded-lg space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">类型</span>
|
||
<span className="font-medium">{transactionTypeLabels[transactionToDelete.type]}</span>
|
||
</div>
|
||
{transactionToDelete.symbol && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">证券</span>
|
||
<span className="font-mono">{transactionToDelete.symbol}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">金额</span>
|
||
<span className="font-mono font-medium">{formatCurrency(parseFloat(transactionToDelete.amount), transactionToDelete.currency)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">时间</span>
|
||
<span className="text-sm">{new Date(transactionToDelete.executedAt).toLocaleString('zh-CN')}</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-sm text-destructive text-center">
|
||
确定要删除此交易记录吗?此操作将回滚相关账户余额和持仓变化
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter className="gap-2 sm:gap-0">
|
||
<Button variant="outline" onClick={() => {
|
||
setShowDeleteTxDialog(false)
|
||
setTransactionToDelete(null)
|
||
}}>
|
||
取消
|
||
</Button>
|
||
<Button variant="destructive" onClick={handleDeleteTransaction}>
|
||
<Trash2 className="h-4 w-4 mr-1" />
|
||
确认删除
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 删除持仓确认对话框 */}
|
||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||
<DialogContent className="sm:max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>确认删除持仓</DialogTitle>
|
||
</DialogHeader>
|
||
{positionToDelete && (
|
||
<div className="py-4 space-y-3">
|
||
<div className="text-center py-2">
|
||
<div className="text-lg font-bold">{positionToDelete.symbol}</div>
|
||
<div className="text-sm text-muted-foreground">{positionToDelete.name}</div>
|
||
</div>
|
||
<div className="bg-muted/50 p-3 rounded-lg space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">数量</span>
|
||
<span className="font-mono">{positionToDelete.quantity.toFixed(positionToDelete.isCrypto ? 6 : 0)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">当前价</span>
|
||
<span className="font-mono">{formatCurrency(positionToDelete.currentPrice, positionToDelete.currency)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">市值</span>
|
||
<span className="font-mono font-bold">{formatCurrency(positionToDelete.marketValue, positionToDelete.currency)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-sm text-destructive text-center">
|
||
确定要删除此持仓吗?这将自动卖出全部持仓
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter className="gap-2 sm:gap-0">
|
||
<Button variant="outline" onClick={() => {
|
||
setShowDeleteDialog(false)
|
||
setPositionToDelete(null)
|
||
}}>
|
||
取消
|
||
</Button>
|
||
<Button variant="destructive" onClick={handleDeletePosition}>
|
||
<Trash2 className="h-4 w-4 mr-1" />
|
||
确认删除
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 导入对话框 */}
|
||
<Dialog open={showImportDialog} onOpenChange={setShowImportDialog}>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>导入交易记录</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="py-4 space-y-4">
|
||
{/* 下载模板按钮 */}
|
||
<div className="flex items-center gap-4">
|
||
<Button variant="outline" onClick={handleDownloadTemplate}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
下载模板
|
||
</Button>
|
||
<span className="text-sm text-muted-foreground">先下载模板,填写后导入</span>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* 文件选择 */}
|
||
<div className="space-y-2">
|
||
<Label>选择 CSV 文件</Label>
|
||
<Input
|
||
type="file"
|
||
accept=".csv"
|
||
onChange={handleFileImport}
|
||
/>
|
||
</div>
|
||
|
||
{/* 导入预览表格 */}
|
||
{importFile && (
|
||
<div className="space-y-2">
|
||
<div className="text-sm font-medium">导入预览 ({importData.length} 条)</div>
|
||
<div className="max-h-64 overflow-auto border rounded-lg">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>时间</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>证券</TableHead>
|
||
<TableHead className="text-right">金额</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{importData.slice(0, 10).map((tx, index) => (
|
||
<TableRow key={index}>
|
||
<TableCell>
|
||
{tx.errors.length > 0 ? (
|
||
<Badge variant="destructive" className="text-xs">
|
||
错误 {tx.errors.length}
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="default" className="text-xs">有效</Badge>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-sm">{tx.executedAt}</TableCell>
|
||
<TableCell className="text-sm">{tx.type}</TableCell>
|
||
<TableCell className="text-sm">{tx.symbol || '-'}</TableCell>
|
||
<TableCell className="text-right font-mono">{tx.amount}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
{importData.length > 10 && (
|
||
<div className="p-2 text-center text-sm text-muted-foreground">
|
||
还有 {importData.length - 10} 条记录...
|
||
</div>
|
||
)}
|
||
</div>
|
||
{importData.some(tx => tx.errors.length > 0) && (
|
||
<div className="text-sm text-destructive">
|
||
部分记录存在错误,将被跳过
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => {
|
||
setShowImportDialog(false)
|
||
setImportData([])
|
||
setImportFile(null)
|
||
}}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleExecuteImport}
|
||
disabled={importData.filter(tx => tx.errors.length === 0).length === 0}
|
||
>
|
||
导入 {importData.filter(tx => tx.errors.length === 0).length} 条记录
|
||
</Button>
|
||
</DialogFooter>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|