'use client'; import React, { useState, useEffect, useTransition } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { toast } from 'sonner'; import { getPortfolioSummary } from '@/actions/portfolio'; import { getAssets } from '@/actions/asset'; import { recordDailySnapshot, getSnapshots, reconstructPortfolioHistory } from '@/actions/snapshots'; import { formatQuantity, formatAmount } from '@/lib/formatters'; import AllocationChart from '@/components/dashboard/allocation-chart'; import NetWorthChart from '@/components/dashboard/net-worth-chart'; import { SyncButton } from '@/components/assets/sync-button'; import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog'; import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog'; import { deleteTransaction } from '@/actions/transaction'; import { importHistoricalPrices } from '@/actions/market'; import { ChevronDown, ChevronUp, Plus, Edit3, Trash2, Upload, Download, Eye } from 'lucide-react'; import Big from 'big.js'; const txTypeMap: Record = { BUY: '买入', SELL: '卖出', DIVIDEND: '分红', AIRDROP: '空投', }; function getCurrencySymbol(currency: string): string { if (currency === 'USD') return '$'; if (currency === 'HKD') return 'HK$'; if (currency === 'CNY') return '¥'; if (currency === 'JPY') return '¥'; return currency + ' '; } function formatNative(value: string, baseCurrency: string): string { const symbol = getCurrencySymbol(baseCurrency); const formatted = new Big(value).toFixed(2); return `${symbol}${formatted}`; } function exportToCSV(positions: any[]) { const stripTrailingZeros = (val: any): string => { if (val === null || val === undefined || val === '') return "0"; let str = String(val); if (str.includes('.')) { str = str.replace(/0+$/, ''); str = str.replace(/\.$/, ''); } return str; }; const getMarketName = (item: any): string => { if (item.type === 'CRYPTO' || item.assetType === 'CRYPTO') return '虚拟币'; const currency = (item.baseCurrency || '').toUpperCase(); if (currency === 'USD') return '美股'; if (currency === 'HKD') return '港股'; if (currency === 'CNY' || currency === 'RMB') return 'A股'; const symbol = (item.symbol || '').toLowerCase(); if (/^\d{5}$/.test(symbol)) return '港股'; if (/^(60|00|30)\d{4}$/.test(symbol) || symbol.startsWith('sh') || symbol.startsWith('sz')) return 'A股'; return '其他市场'; }; const headers = ["资产名称", "代码", "市场", "持仓量", "成本价", "现价", "总市值", "浮动盈亏", "累计盈亏"]; const rows = positions.map(item => [ item.name || item.symbol, item.symbol, getMarketName(item), item.quantity || '0', stripTrailingZeros(new Big(item.avgCostNative || '0').toFixed(2)), stripTrailingZeros(item.latestPrice || '0'), new Big(item.marketValueNative || '0').toFixed(2), new Big(item.floatingPnlNative || '0').toFixed(2), new Big(item.cumulativePnlNative || '0').toFixed(2), ]); const csvContent = [ headers.join(","), ...rows.map(e => e.map(val => `"${val}"`).join(",")) ].join("\n"); const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.setAttribute("href", url); link.setAttribute("download", `portfolio_details_${new Date().toISOString().split('T')[0]}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } function formatPnl(value: string, percent: string, baseCurrency: string): { text: string; className: string } { const isPositive = new Big(value).gte(0); const symbol = getCurrencySymbol(baseCurrency); const absValue = new Big(value).abs().toFixed(2); const absPercent = new Big(percent).abs().toFixed(2); const text = `${symbol}${isPositive ? '' : '-'}${absValue} (${isPositive ? '' : '-'}${absPercent}%)`; const className = isPositive ? 'text-red-500' : 'text-green-500'; return { text, className }; } interface Asset { id: string; symbol: string; name: string | null; type: string; baseCurrency: string; exchange: string | null; } export default function DashboardPage() { const [positions, setPositions] = useState([]); const [totalCnyValue, setTotalCnyValue] = useState('0'); const [totalPnlCny, setTotalPnlCny] = useState('0'); const [unrealizedPnlCny, setUnrealizedPnlCny] = useState('0'); const [marketAllocation, setMarketAllocation] = useState([]); const [assets, setAssets] = useState([]); const [expandedIds, setExpandedIds] = useState>({}); const [dialogOpen, setDialogOpen] = useState(false); const [selectedAssetId, setSelectedAssetId] = useState(''); const [isPending, startTransition] = useTransition(); const [updateTarget, setUpdateTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [snapshots, setSnapshots] = useState([]); const [importDialogOpen, setImportDialogOpen] = useState(false); const [importAssetId, setImportAssetId] = useState(''); const [importText, setImportText] = useState(''); const [showCleared, setShowCleared] = useState(false); const [positionsRaw, setPositionsRaw] = useState([]); useEffect(() => { const filtered = showCleared ? positionsRaw : positionsRaw.filter(pos => new Big(pos.quantity || '0').gt('1e-8')); setPositions(filtered); }, [showCleared, positionsRaw]); useEffect(() => { async function loadData() { const summary = await getPortfolioSummary(true); const allAssets = await getAssets(); setPositionsRaw(summary.positions); setPositions(summary.positions); setTotalCnyValue(summary.totalCnyValue); setTotalPnlCny(summary.totalPnlCny); setUnrealizedPnlCny(summary.unrealizedPnlCny); setMarketAllocation(summary.marketAllocation); setAssets(allAssets as Asset[]); } loadData(); }, []); useEffect(() => { async function loadSnapshots() { const summary = await getPortfolioSummary(); await recordDailySnapshot(); const data = await getSnapshots(); const todayStr = new Date().toISOString().slice(0, 10); const lastSnapshot = data[data.length - 1]; if (lastSnapshot && lastSnapshot.date === todayStr) { lastSnapshot.totalValueCny = summary.totalCnyValue; lastSnapshot.totalCostCny = summary.totalCostCny; } else { // 注入虚拟主键与时间戳,完美骗过 TypeScript 的强类型校验 data.push({ id: 'virtual_today_node', date: todayStr, totalValueCny: summary.totalCnyValue, totalCostCny: summary.totalCostCny, createdAt: new Date(), updatedAt: new Date(), }); } setSnapshots(data); } loadSnapshots(); }, []); const toggleExpand = (id: string) => { setExpandedIds(prev => ({ ...prev, [id]: !prev[id] })); }; const handleOpenDialog = (assetId: string) => { setSelectedAssetId(assetId); setDialogOpen(true); }; const handleUpdate = (tx: any) => { setUpdateTarget(tx); }; function handleUpdateSubmit() { setUpdateTarget(null); } function handleDelete(tx: any) { startTransition(async () => { const result = await deleteTransaction(tx.id); if (result.success) { toast.success('交易記錄已刪除'); setDeleteTarget(null); window.location.reload(); } else if (result.error) { toast.error(result.error); } }); } function handleOpenImportDialog(assetId: string) { setImportAssetId(assetId); setImportText(''); setImportDialogOpen(true); } function handleImportSubmit() { startTransition(async () => { const lines = importText.split('\n').filter(l => l.trim()); const data: Array<{ date: string; price: string }> = []; for (const line of lines) { const parts = line.split(','); if (parts.length >= 2) { const date = parts[0].trim(); const price = parts[1].trim(); if (date && price) { data.push({ date, price }); } } } if (data.length === 0) { toast.error('未解析到有效數據,請檢查格式'); setImportDialogOpen(false); return; } const result = await importHistoricalPrices(importAssetId, data); if (result.success) { toast.success(`成功導入 ${result.imported} 條價格記錄` + (result.errors ? `,${result.errors} 條失敗` : '')); setImportText(''); setImportDialogOpen(false); window.location.reload(); } else { toast.error(result.error || '導入失敗'); } }); } const formattedTotal = formatAmount(totalCnyValue); const formattedTotalPnl = formatAmount(totalPnlCny); const formattedUnrealized = formatAmount(unrealizedPnlCny); const totalPnlIsPositive = new Big(totalPnlCny).gte(0); const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0); return (

欢迎来到 Omniledger

您的跨界记账中枢。

总资产概览
¥ {formattedTotal} 总资产 (CNY)
持仓盈亏: {formattedUnrealized}
总盈亏: {formattedTotalPnl}
净值走势 持仓明细
{positions.length === 0 ? (
暂无持仓,请先添加资产和交易记录。
) : ( 名称/代码 现價 市值 持倉 攤薄/成本 浮動盈虧 累計盈虧 操作 {positions.map((pos) => { const symbol = getCurrencySymbol(pos.baseCurrency); const latestPriceFormatted = `${symbol}${new Big(pos.latestPrice || '0').toFixed(2)}`; const marketValueFormatted = formatNative(pos.marketValueNative, pos.baseCurrency); const quantityFormatted = formatQuantity(pos.quantity, pos.type); const dilutedCostStr = new Big(pos.dilutedCostNative).toFixed(2); const avgCostStr = new Big(pos.avgCostNative).toFixed(2); let costDisplay = '-'; if (!new Big(pos.dilutedCostNative).eq('0') || !new Big(pos.avgCostNative).eq('0')) { costDisplay = `${dilutedCostStr} / ${avgCostStr}`; } const floatingPnl = formatPnl(pos.floatingPnlNative, pos.floatingPnlPercent, pos.baseCurrency); const cumulativePnl = formatPnl(pos.cumulativePnlNative, pos.cumulativePnlPercent, pos.baseCurrency); const isExpanded = !!expandedIds[pos.assetId]; return ( toggleExpand(pos.assetId)}>
{pos.name || pos.symbol} {pos.symbol}
{latestPriceFormatted} {marketValueFormatted} {quantityFormatted} {costDisplay} {floatingPnl.text} {cumulativePnl.text} e.stopPropagation()}>
{isExpanded ? '收起' : '展开'} {isExpanded ? ( ) : ( )}
{isExpanded && (
流水明細
{pos.transactions && pos.transactions.length > 0 ? (
交易日期 類型 價格/數量 手續費 備註 操作 {pos.transactions.map((tx: any) => { const txDate = tx.executedAt ? new Date(tx.executedAt).toLocaleString('zh-TW', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) : '-'; return ( {txDate} {txTypeMap[tx.txType] || tx.txType}
量: {new Big(tx.quantity).toString()} 價: {new Big(tx.price).toString()}
{new Big(tx.fee || 0).toString()} -
); })}
) : (

暫無交易記錄

)}
)} ); })} )} 資產分布 !open && setUpdateTarget(null)} assets={assets} transaction={updateTarget} onSuccess={handleUpdateSubmit} /> !open && setDeleteTarget(null)}> 確認刪除 確定要刪除這筆交易記錄嗎?此操作不可撤銷。 导入历史价格 從 Excel 複製粘貼價格數據,格式:日期, 價格(每行一條)