From f6436db200dd1a6b75d12c65427af5b0879e038c Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sun, 12 Apr 2026 05:09:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 6 +- package-lock.json | 22 + package.json | 2 + src/app/api/dashboard/analytics/route.ts | 164 ++++ src/app/api/import/transactions/route.ts | 110 +++ src/app/layout.tsx | 36 +- src/app/page.tsx | 1096 ++++++++++++++++++++-- src/components/ui/badge.tsx | 52 + src/components/ui/card.tsx | 103 ++ src/components/ui/dialog.tsx | 160 ++++ src/components/ui/dropdown-menu.tsx | 268 ++++++ src/components/ui/input.tsx | 20 + src/components/ui/label.tsx | 20 + src/components/ui/select.tsx | 201 ++++ src/components/ui/separator.tsx | 25 + src/components/ui/sonner.tsx | 49 + src/components/ui/table.tsx | 116 +++ src/components/ui/tabs.tsx | 82 ++ src/lib/api.ts | 151 +++ src/lib/import-export.ts | 193 ++++ src/lib/price-service.ts | 76 ++ src/types/index.ts | 114 +++ 22 files changed, 2979 insertions(+), 87 deletions(-) create mode 100644 src/app/api/dashboard/analytics/route.ts create mode 100644 src/app/api/import/transactions/route.ts create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/import-export.ts create mode 100644 src/lib/price-service.ts create mode 100644 src/types/index.ts diff --git a/next.config.ts b/next.config.ts index e9ffa30..c73f2b4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + allowedDevOrigins: ['192.168.50.50', 'localhost'], + env: { + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 208f3fc..9f61123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,12 @@ "clsx": "^2.1.1", "lucide-react": "^1.8.0", "next": "16.2.3", + "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^3.8.1", "shadcn": "^4.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, @@ -7658,6 +7660,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -9281,6 +9293,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 640c143..2dc5a0d 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ "clsx": "^2.1.1", "lucide-react": "^1.8.0", "next": "16.2.3", + "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^3.8.1", "shadcn": "^4.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, diff --git a/src/app/api/dashboard/analytics/route.ts b/src/app/api/dashboard/analytics/route.ts new file mode 100644 index 0000000..4e99b08 --- /dev/null +++ b/src/app/api/dashboard/analytics/route.ts @@ -0,0 +1,164 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +// Alpha Vantage API 配置 +const ALPHA_VANTAGE_BASE_URL = 'https://www.alphavantage.co/query' +const API_KEY = process.env.ALPHA_VANTAGE_API_KEY || 'EZ4LLXN8DW0J4G7P' + +interface StockQuote { + '01. symbol': string + '05. price': string + '09. change': string + '10. change percent': string + '06. volume': string + '07. latest trading day': string +} + +async function fetchStockQuote(symbol: string): Promise<{ price: number; change: number; changePercent: number } | null> { + try { + const url = `${ALPHA_VANTAGE_BASE_URL}?function=GLOBAL_QUOTE&symbol=${symbol}&apikey=${API_KEY}` + + const response = await fetch(url, { + next: { revalidate: 60 }, // 缓存1分钟 + }) + + if (!response.ok) return null + + const data = await response.json() + const quote = data['Global Quote'] as StockQuote | undefined + + if (quote && quote['05. price']) { + return { + price: parseFloat(quote['05. price']), + change: parseFloat(quote['09. change'] || '0'), + changePercent: parseFloat(quote['10. change percent']?.replace('%', '') || '0'), + } + } + + return null + } catch (error) { + console.error(`Failed to fetch quote for ${symbol}:`, error) + return null + } +} + +// 获取持仓实时价格 +export async function GET() { + try { + // 1. 获取所有持仓 + const positions = await prisma.position.findMany({ + where: { account: { user: { email: 'demo@portfolio.local' } } }, + include: { + account: { select: { name: true, marketType: true, baseCurrency: true } }, + }, + }) + + if (positions.length === 0) { + return NextResponse.json({ prices: {}, quotes: {} }) + } + + // 2. 获取证券信息 + const symbols = positions.map((p) => p.symbol) + const securities = await prisma.security.findMany({ + where: { symbol: { in: symbols } }, + }) + const securityMap = new Map(securities.map((s) => [s.symbol, s])) + + // 3. 获取最新汇率 + const latestRates = await prisma.exchangeRate.findMany({ + orderBy: { effectiveDate: 'desc' }, + }) + const rateMap = new Map( + latestRates.map((r) => [`${r.fromCurrency}_${r.toCurrency}`, Number(r.rate)]) + ) + + // 4. 获取实时价格(只获取美股) + const usSymbols = positions + .filter((p) => p.account.marketType === 'US') + .map((p) => p.symbol) + + const priceResults: Record = {} + + // 并行获取价格(限制并发数) + const batchSize = 3 + for (let i = 0; i < usSymbols.length; i += batchSize) { + const batch = usSymbols.slice(i, i + batchSize) + const results = await Promise.all(batch.map((s) => fetchStockQuote(s))) + batch.forEach((symbol, index) => { + if (results[index]) { + priceResults[symbol] = results[index]! + } + }) + } + + // 5. 计算完整的持仓分析 + const positionAnalytics = positions.map((pos) => { + const security = securityMap.get(pos.symbol) + const quote = priceResults[pos.symbol] + const currentPrice = quote?.price || Number(pos.averageCost) + const qty = Number(pos.quantity) + const avgCost = Number(pos.averageCost) + const costBasis = qty * avgCost + const marketValue = qty * currentPrice + const pnl = marketValue - costBasis + const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0 + + const rate = rateMap.get(`${pos.currency}_USD`) || 1 + const marketValueUSD = marketValue * rate + const pnlUSD = pnl * rate + + return { + symbol: pos.symbol, + name: security?.name || pos.symbol, + marketType: pos.account.marketType, + accountName: pos.account.name, + quantity: qty, + avgCost, + currentPrice, + change: quote?.change || 0, + changePercent: quote?.changePercent || 0, + costBasis, + marketValue, + marketValueUSD, + pnl, + pnlPercent, + pnlUSD, + currency: pos.currency, + isCrypto: security?.isCrypto || false, + } + }) + + // 6. 汇总统计 + const totalCostBasis = positionAnalytics.reduce((sum, p) => sum + p.costBasis, 0) + const totalMarketValue = positionAnalytics.reduce((sum, p) => sum + p.marketValueUSD, 0) + const totalPnL = totalMarketValue - totalCostBasis + const totalPnLPercent = totalCostBasis > 0 ? (totalPnL / totalCostBasis) * 100 : 0 + + // 7. 按市场分组 + const byMarket = positionAnalytics.reduce((acc, p) => { + if (!acc[p.marketType]) { + acc[p.marketType] = { totalCost: 0, totalValue: 0, totalPnL: 0 } + } + acc[p.marketType].totalCost += p.costBasis + acc[p.marketType].totalValue += p.marketValueUSD + acc[p.marketType].totalPnL += p.pnlUSD + return acc + }, {} as Record) + + return NextResponse.json({ + prices: priceResults, + positions: positionAnalytics, + summary: { + totalCostBasis, + totalMarketValue, + totalPnL, + totalPnLPercent, + positionCount: positions.length, + }, + byMarket, + }) + } catch (error) { + console.error('Analytics error:', error) + return NextResponse.json({ error: 'Failed to fetch analytics' }, { status: 500 }) + } +} diff --git a/src/app/api/import/transactions/route.ts b/src/app/api/import/transactions/route.ts new file mode 100644 index 0000000..808d79b --- /dev/null +++ b/src/app/api/import/transactions/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { Prisma } from '@prisma/client' + +// 导入交易记录 +export async function POST(request: Request) { + try { + const body = await request.json() + const { transactions, accountId } = body + + if (!transactions || !Array.isArray(transactions)) { + return NextResponse.json({ error: 'Invalid transactions data' }, { status: 400 }) + } + + if (!accountId) { + return NextResponse.json({ error: 'Account ID is required' }, { status: 400 }) + } + + const results = { + success: 0, + failed: 0, + errors: [] as { row: number; error: string }[], + } + + // 逐条处理交易 + for (let i = 0; i < transactions.length; i++) { + const tx = transactions[i] + + try { + await prisma.$transaction(async (txClient) => { + // 1. 创建交易记录 + await txClient.transaction.create({ + data: { + accountId, + type: tx.type, + symbol: tx.symbol || null, + quantity: tx.quantity ? new Prisma.Decimal(tx.quantity) : null, + price: tx.price ? new Prisma.Decimal(tx.price) : null, + amount: new Prisma.Decimal(tx.amount), + fee: new Prisma.Decimal(tx.fee || 0), + currency: tx.currency, + exchangeRate: tx.exchangeRate ? new Prisma.Decimal(tx.exchangeRate) : null, + notes: tx.notes || null, + executedAt: new Date(tx.executedAt), + }, + }) + + // 2. 更新账户余额 + if (tx.type === 'DEPOSIT' || tx.type === 'WITHDRAW') { + const balanceChange = tx.type === 'DEPOSIT' + ? new Prisma.Decimal(tx.amount) + : new Prisma.Decimal(tx.amount).negated() + await txClient.account.update({ + where: { id: accountId }, + data: { balance: { increment: balanceChange } }, + }) + } + + // 3. 更新持仓 + if ((tx.type === 'BUY' || tx.type === 'SELL') && tx.symbol && tx.quantity && tx.price) { + const position = await txClient.position.findUnique({ + where: { accountId_symbol: { accountId, symbol: tx.symbol } }, + }) + + if (tx.type === 'BUY') { + const buyAmount = new Prisma.Decimal(tx.quantity).times(new Prisma.Decimal(tx.price)) + if (position) { + const newQty = position.quantity.plus(new Prisma.Decimal(tx.quantity)) + const newCost = position.averageCost.times(position.quantity).plus(buyAmount).div(newQty) + await txClient.position.update({ + where: { accountId_symbol: { accountId, symbol: tx.symbol } }, + data: { quantity: newQty, averageCost: newCost }, + }) + } else { + await txClient.position.create({ + data: { + accountId, + symbol: tx.symbol, + quantity: new Prisma.Decimal(tx.quantity), + averageCost: new Prisma.Decimal(tx.price), + currency: tx.currency, + }, + }) + } + } else if (tx.type === 'SELL' && position) { + const newQty = position.quantity.minus(new Prisma.Decimal(tx.quantity)) + await txClient.position.update({ + where: { accountId_symbol: { accountId, symbol: tx.symbol } }, + data: { quantity: newQty }, + }) + } + } + }) + + results.success++ + } catch (error) { + results.failed++ + results.errors.push({ + row: i + 1, + error: error instanceof Error ? error.message : 'Unknown error', + }) + } + } + + return NextResponse.json(results) + } catch (error) { + console.error('Import error:', error) + return NextResponse.json({ error: 'Failed to import transactions' }, { status: 500 }) + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..5fcd69e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,33 +1,23 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata } from "next" +import "./globals.css" +import { Toaster } from "@/components/ui/sonner" export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; + title: "个人投资持仓管理", + description: "管理您的多市场投资组合", +} export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( - - {children} + + + {children} + + - ); + ) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..f6ce3d3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,1035 @@ -import Image from "next/image"; +'use client' -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); +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, DialogTrigger, DialogFooter } from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { Tabs as TabsPrimitive } from '@/components/ui/tabs' +import { + PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, + LineChart, Line, XAxis, YAxis, CartesianGrid +} from 'recharts' +import { + Wallet, TrendingUp, TrendingDown, Plus, ArrowUpRight, ArrowDownRight, + Bitcoin, Building2, Globe2, RefreshCw, DollarSign, Search, Check, + Download, Upload, BarChart3, TrendingUpIcon +} from 'lucide-react' +import { + fetchAccounts, fetchTransactions, fetchPositions, + fetchSecurities, createTransaction, 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 = { + US: , + CN: , + HK: , + CRYPTO: , +} + +const marketColors: Record = { + 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 +} + +export default function Dashboard() { + const [accounts, setAccounts] = useState([]) + const [transactions, setTransactions] = useState([]) + const [positions, setPositions] = useState([]) + const [securities, setSecurities] = useState([]) + const [analytics, setAnalytics] = useState<{ prices: Record; positions: PositionAnalytics[]; summary: AnalyticsSummary } | null>(null) + const [loading, setLoading] = useState(true) + const [selectedAccount, setSelectedAccount] = useState('') + const [showTxDialog, setShowTxDialog] = useState(false) + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + const [showImportDialog, setShowImportDialog] = useState(false) + const [symbolSearch, setSymbolSearch] = useState('') + const [filteredSecurities, setFilteredSecurities] = useState([]) + const [importFile, setImportFile] = useState(null) + const [importData, setImportData] = useState([]) + 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 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 && !selectedAccount) { + setSelectedAccount(accountsData[0].id) + } + } catch (error) { + toast.error('加载数据失败') + } finally { + setLoading(false) + } + }, [selectedAccount]) + + 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 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 handleSubmitTx = async () => { + try { + const accountId = selectedAccount || accounts[0]?.id + if (!accountId) { + toast.error('请先选择账户') + return + } + + await createTransaction({ + accountId, + type: txForm.type, + symbol: txForm.symbol || undefined, + quantity: txForm.quantity ? parseFloat(txForm.quantity) : undefined, + price: txForm.price ? parseFloat(txForm.price) : undefined, + amount: parseFloat(txForm.amount), + 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 resetTxForm = () => { + const account = accounts.find(a => a.id === selectedAccount) + setTxForm({ + type: 'BUY', + symbol: '', + quantity: '', + price: '', + amount: '', + fee: '0', + currency: account?.baseCurrency || 'USD', + notes: '', + executedAt: new Date().toISOString().slice(0, 16), + }) + } + + // 打开交易对话框 + const openTxDialog = () => { + resetTxForm() + setShowTxDialog(true) + } + + // 计算确认信息 + const getConfirmInfo = () => { + const account = accounts.find(a => a.id === selectedAccount) + 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 totalLabel = txForm.amount ? `合计 ${formatCurrency(parseFloat(txForm.amount), txForm.currency)}` : '' + + return { + account: account?.name || '未选择', + market: account ? marketLabels[account.marketType] : '', + action: `${typeLabel} ${symbolLabel}${qtyLabel}${priceLabel}`, + total: totalLabel, + fee: txForm.fee && parseFloat(txForm.fee) > 0 ? `手续费: ${formatCurrency(parseFloat(txForm.fee), txForm.currency)}` : '', + time: new Date(txForm.executedAt).toLocaleString('zh-CN'), + } + } + + // 导出交易记录 + const handleExportTransactions = () => { + const csv = exportTransactionsToCSV(transactions) + const date = new Date().toISOString().slice(0, 10) + downloadCSV(csv, `transactions_${date}.csv`) + toast.success('交易记录已导出') + } + + // 导出持仓 + 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('持仓记录已导出') + } + + // 下载导入模板 + const handleDownloadTemplate = () => { + downloadCSV(TRANSACTION_IMPORT_TEMPLATE, 'transaction_import_template.csv') + } + + // 处理文件导入 + const handleFileImport = async (e: React.ChangeEvent) => { + 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 (!selectedAccount) { + 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: selectedAccount, + }), + }) + + 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 ( +
+ +
+ ) + } + + return ( +
+ {/* 顶部导航 */} +
+
+
+ +

投资持仓管理

+
+
+ + + + +
+
+
+ +
+ {/* 资产概览 */} +
+ + + 总资产 (USD) + + + +
{formatCurrency(analytics?.summary?.totalMarketValue || 0)}
+

+ 成本 {formatCurrency(analytics?.summary?.totalCostBasis || 0)} +

+
+
+ + + + 浮动盈亏 + + + +
{formatCurrency(analytics?.summary?.totalPnL || 0)}
+

= 0 ? 'text-emerald-200' : 'text-red-200'}`}> + {(analytics?.summary?.totalPnL || 0) >= 0 ? : } + {formatPercent(analytics?.summary?.totalPnLPercent || 0)} +

+
+
+ + + + 持仓市值 + + + +
{formatCurrency(analytics?.summary?.totalMarketValue || 0)}
+

{analytics?.summary?.positionCount || 0} 个持仓

+
+
+ + + + 账户数量 + + + +
{accounts.length}
+

分布在 {accounts.length} 个市场

+
+
+
+ + {/* 图表区域 */} +
+ {/* 市场分布饼图 */} + + + 资产配置 + + + {marketDistribution.length > 0 ? ( + + + + {marketDistribution.map((entry, index) => ( + + ))} + + formatCurrency(value as number)} + contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)' }} + /> + + + + ) : ( +
+ 暂无数据 +
+ )} +
+
+ + {/* 盈亏统计 */} + + + 持仓分析 + + +
+ {analytics?.positions?.slice(0, 4).map((pos) => ( +
+
+ {marketIcons[pos.marketType as MarketType]} + {pos.symbol} +
+
{formatCurrency(pos.marketValueUSD)}
+ +
= 0 ? 'text-green-500' : 'text-red-500'}`}> + {pos.pnl >= 0 ? : } + {formatCurrency(Math.abs(pos.pnlUSD))} ({formatPercent(pos.pnlPercent)}) +
+
+ ))} +
+
+
+
+ + {/* 持仓和交易标签页 */} + + + 持仓明细 + 交易流水 + 分析 + + + + + + + + + 证券 + 市场 + 数量 + 成本价 + 当前价 + 市值 + 盈亏 + + + + {analytics?.positions && analytics.positions.length > 0 ? analytics.positions?.map((pos) => ( + + +
{pos.symbol}
+
{pos.name}
+
+ + + {marketIcons[pos.marketType as MarketType]} + {marketLabels[pos.marketType as MarketType]} + + + + {pos.quantity.toFixed(pos.isCrypto ? 6 : 0)} + + + {formatCurrency(pos.avgCost, pos.currency)} + + +
+ {formatCurrency(pos.currentPrice, pos.currency)} + {pos.change !== 0 && ( + = 0 ? 'text-green-500' : 'text-red-500'}`}> + {pos.change >= 0 ? '+' : ''}{pos.changePercent.toFixed(2)}% + + )} +
+
+ + {formatCurrency(pos.marketValue, pos.currency)} + + = 0 ? 'text-green-500' : 'text-red-500'}`}> +
+ {pos.pnl >= 0 ? : } + {formatCurrency(Math.abs(pos.pnl))} + ({formatPercent(pos.pnlPercent)}) +
+
+
+ )) : ( + + + 暂无持仓记录 + + + )} +
+
+
+
+
+ + + + + 交易流水 + + + + + + + 时间 + 类型 + 证券 + 数量 + 价格 + 金额 + 账户 + + + + {transactions.length > 0 ? transactions.map((tx) => ( + + + {new Date(tx.executedAt).toLocaleDateString('zh-CN')} + + + + {transactionTypeLabels[tx.type]} + + + {tx.symbol || '-'} + + {tx.quantity ? parseFloat(tx.quantity).toFixed(4) : '-'} + + + {tx.price ? formatCurrency(parseFloat(tx.price), tx.currency) : '-'} + + + {formatCurrency(parseFloat(tx.amount), tx.currency)} + + +
+ {marketIcons[tx.account?.marketType || 'US']} + {tx.account?.name} +
+
+
+ )) : ( + + + 暂无交易记录 + + + )} +
+
+
+
+
+ + +
+ {/* 资产增长趋势 */} + + + 资产分布 + + +
+ {analytics?.positions?.map((pos) => { + const percent = analytics?.summary?.totalMarketValue && analytics.summary.totalMarketValue > 0 + ? (pos.marketValueUSD / analytics.summary.totalMarketValue) * 100 + : 0 + return ( +
+
+
+ {marketIcons[pos.marketType as MarketType]} + {pos.symbol} +
+
+ {percent.toFixed(1)}% + {formatCurrency(pos.marketValueUSD)} +
+
+
+
+
+
+ ) + })} +
+ + + + {/* 盈亏排行榜 */} + + + 盈亏排行 + + +
+ {analytics?.positions + ?.sort((a, b) => b.pnlUSD - a.pnlUSD) + .map((pos, index) => ( +
+
+ #{index + 1} +
+
{pos.symbol}
+
{pos.name}
+
+
+
= 0 ? 'text-green-500' : 'text-red-500'}`}> +
+ {pos.pnl >= 0 ? : } + {formatCurrency(Math.abs(pos.pnlUSD))} +
+
{formatPercent(pos.pnlPercent)}
+
+
+ ))} +
+
+
+
+ + +
+ + {/* 记录交易对话框 */} + + + + 记录交易 + +
+
+
+ + +
+
+ + +
+
+ + {['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && ( +
+ +
+ + { + setSymbolSearch(e.target.value) + setTxForm({ ...txForm, symbol: e.target.value.toUpperCase() }) + }} + /> + {filteredSecurities.length > 0 && ( +
+ {filteredSecurities.map((sec) => ( + + ))} +
+ )} +
+
+ )} + + {['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && ( +
+
+ + setTxForm({ ...txForm, quantity: e.target.value })} + /> +
+
+ + setTxForm({ ...txForm, price: e.target.value })} + /> +
+
+ )} + +
+ + setTxForm({ ...txForm, amount: e.target.value })} + /> +
+ +
+ + setTxForm({ ...txForm, fee: e.target.value })} + /> +
+ +
+ + setTxForm({ ...txForm, executedAt: e.target.value })} + /> +
+ +
+ + setTxForm({ ...txForm, notes: e.target.value })} + /> +
+ + +
+
+
+ + {/* 确认对话框 */} + + + + 确认交易信息 + +
+ {(() => { + const info = getConfirmInfo() + return ( + <> +
+ 账户: + {info.account} + {info.market} +
+ +
+
{info.action}
+
+
+ 成交总额 + {info.total} +
+ {info.fee && ( +
+ {info.fee} +
+ )} + +
+ 交易时间: {info.time} +
+ + ) + })()} +
+ + + + +
+
+ + {/* 导入对话框 */} + + + + 导入交易记录 + +
+
+ + 先下载模板,填写后导入 +
+ + + +
+ + +
+ + {importFile && ( +
+
导入预览 ({importData.length} 条)
+
+ + + + 状态 + 时间 + 类型 + 证券 + 金额 + + + + {importData.slice(0, 10).map((tx, index) => ( + + + {tx.errors.length > 0 ? ( + + 错误 {tx.errors.length} + + ) : ( + 有效 + )} + + {tx.executedAt} + {tx.type} + {tx.symbol || '-'} + {tx.amount} + + ))} + +
+ {importData.length > 10 && ( +
+ 还有 {importData.length - 10} 条记录... +
+ )} +
+ {importData.some(tx => tx.errors.length > 0) && ( +
+ 部分记录存在错误,将被跳过 +
+ )} +
+ )} + + + + + +
+
+
+
+ ) } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..014f5aa --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9d5ebbd --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,268 @@ +"use client" + +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +