diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 4e3a625..3d1ca49 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,6 +1,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { getPortfolioPositions } from '@/actions/portfolio'; -import { formatQuantity } from '@/lib/formatters'; +import { getPortfolioSummary } from '@/actions/portfolio'; +import { formatQuantity, formatAmount } from '@/lib/formatters'; import AllocationChart from '@/components/dashboard/allocation-chart'; const CHART_COLORS = [ @@ -13,12 +13,13 @@ const CHART_COLORS = [ ]; export default async function DashboardPage() { - const positions = await getPortfolioPositions(); + const { positions, totalCnyValue, chartData } = await getPortfolioSummary(); - const chartData = positions.map((pos, index) => ({ - name: pos.symbol, - value: Number(pos.quantity), - fill: CHART_COLORS[index % CHART_COLORS.length], + const formattedTotal = formatAmount(totalCnyValue); + + const displayChartData = chartData.map((item) => ({ + ...item, + value: Number(item.value), })); return ( @@ -28,6 +29,22 @@ export default async function DashboardPage() {

您的跨界记账中枢。

+ + +
+ + ¥ + + + {formattedTotal} + + + 总资产 (CNY) + +
+
+
+
{positions.length === 0 ? ( @@ -58,6 +75,12 @@ export default async function DashboardPage() { 结算币种 {pos.baseCurrency}
+
+ CNY 估值 + + ¥{formatAmount(pos.cnyValue)} + +
@@ -70,7 +93,7 @@ export default async function DashboardPage() { 资产分布 - + diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 9f1e622..3aa936c 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -1,11 +1,80 @@ 'use server'; import { db } from '@/db'; -import { transactions, assets } from '@/db/schema'; +import { transactions, assets, exchangeRates } from '@/db/schema'; import Big from 'big.js'; import { desc, eq } from 'drizzle-orm'; -export async function getPortfolioPositions() { +interface Position { + assetId: string; + symbol: string; + type: string; + quantity: string; + baseCurrency: string; + cnyValue: string; +} + +interface RawRate { + fromCurrency: string; + toCurrency: string; + rate: string; +} + +function buildRateMap(rates: RawRate[]): Map { + const map = new Map(); + for (const r of rates) { + map.set(`${r.fromCurrency}_${r.toCurrency}`, r.rate); + } + return map; +} + +function getRate( + rateMap: Map, + from: string, + to: string +): string | null { + const direct = rateMap.get(`${from}_${to}`); + if (direct) return direct; + return null; +} + +function calculateCnyValue( + quantity: Big, + baseCurrency: string, + rateMap: Map, + cryptoPrices: Map +): Big { + if (baseCurrency === 'CNY') { + return quantity; + } + + const directRate = getRate(rateMap, baseCurrency, 'CNY'); + if (directRate) { + return quantity.times(directRate); + } + + const usdToCny = getRate(rateMap, 'USD', 'CNY'); + if (!usdToCny) { + return new Big('0'); + } + + const priceKey = `${baseCurrency}_USD`; + const cryptoPrice = cryptoPrices.get(priceKey); + if (cryptoPrice) { + const usdValue = quantity.times(cryptoPrice); + return usdValue.times(usdToCny); + } + + const usdRate = getRate(rateMap, baseCurrency, 'USD'); + if (usdRate) { + const usdValue = quantity.times(usdRate); + return usdValue.times(usdToCny); + } + + return new Big('0'); +} + +export async function getPortfolioPositions(): Promise { const allTransactions = await db .select({ txType: transactions.txType, @@ -14,6 +83,7 @@ export async function getPortfolioPositions() { assetSymbol: assets.symbol, assetType: assets.type, assetBaseCurrency: assets.baseCurrency, + assetPrice: transactions.price, }) .from(transactions) .leftJoin(assets, eq(assets.id, transactions.assetId)) @@ -25,6 +95,7 @@ export async function getPortfolioPositions() { type: string; quantity: Big; baseCurrency: string; + latestPrice: string; }>(); for (const tx of allTransactions) { @@ -38,6 +109,7 @@ export async function getPortfolioPositions() { type: tx.assetType || 'CASH', quantity: new Big('0'), baseCurrency: tx.assetBaseCurrency || '', + latestPrice: tx.assetPrice || '0', }); } @@ -50,19 +122,107 @@ export async function getPortfolioPositions() { } else if (tx.txType === 'DIVIDEND') { holding.quantity = holding.quantity.plus(tx.quantity); } + + if (tx.assetPrice) { + holding.latestPrice = tx.assetPrice; + } } - const result = []; + const rates = await db.select({ + fromCurrency: exchangeRates.fromCurrency, + toCurrency: exchangeRates.toCurrency, + rate: exchangeRates.rate, + }).from(exchangeRates); + + const rateMap = buildRateMap(rates); + + const cryptoSymbols = new Set(['BTC', 'ETH', 'SOL', 'BNB', 'XRP', 'ADA', 'DOGE', 'AVAX', 'MATIC', 'DOT']); + const cryptoPrices = new Map(); + for (const [_, holding] of holdings) { + if (holding.type === 'CRYPTO' && cryptoSymbols.has(holding.symbol.toUpperCase())) { + const priceKey = `${holding.symbol}_USD`; + const usdRate = getRate(rateMap, holding.symbol, 'USD'); + if (usdRate) { + cryptoPrices.set(priceKey, usdRate); + } + } + } + + const result: Position[] = []; + let totalCnyValue = new Big('0'); + for (const [_, holding] of holdings) { if (holding.quantity.lte(0)) continue; + + let cnyValue: Big; + + if (holding.type === 'CRYPTO') { + const symbol = holding.symbol.toUpperCase(); + const btcToUsd = getRate(rateMap, symbol, 'USD'); + const usdToCny = getRate(rateMap, 'USD', 'CNY'); + + if (btcToUsd && usdToCny) { + const usdValue = holding.quantity.times(holding.latestPrice || '1'); + cnyValue = usdValue.times(usdToCny); + } else { + cnyValue = new Big('0'); + } + } else if (holding.baseCurrency === 'CNY') { + cnyValue = holding.quantity.times(holding.latestPrice || '1'); + } else { + const directRate = getRate(rateMap, holding.baseCurrency, 'CNY'); + if (directRate) { + cnyValue = holding.quantity.times(holding.latestPrice || '1').times(directRate); + } else { + const usdRate = getRate(rateMap, holding.baseCurrency, 'USD'); + const usdToCny = getRate(rateMap, 'USD', 'CNY'); + if (usdRate && usdToCny) { + cnyValue = holding.quantity.times(holding.latestPrice || '1').times(usdRate).times(usdToCny); + } else { + cnyValue = new Big('0'); + } + } + } + + totalCnyValue = totalCnyValue.plus(cnyValue); + result.push({ assetId: holding.assetId, symbol: holding.symbol, type: holding.type, quantity: holding.quantity.toString(), baseCurrency: holding.baseCurrency, + cnyValue: cnyValue.toString(), }); } return result; } + +export async function getPortfolioSummary() { + const positions = await getPortfolioPositions(); + + const totalCnyValue = positions.reduce( + (sum, pos) => sum.plus(new Big(pos.cnyValue)), + new Big('0') + ); + + const chartData = positions.map((pos, index) => ({ + name: pos.symbol, + value: new Big(pos.cnyValue), + fill: [ + '#3b82f6', + '#8b5cf6', + '#10b981', + '#f59e0b', + '#ef4444', + '#06b6d4', + ][index % 6], + })); + + return { + positions, + totalCnyValue: totalCnyValue.toString(), + chartData, + }; +} diff --git a/src/components/dashboard/allocation-chart.tsx b/src/components/dashboard/allocation-chart.tsx index 1e05e0f..292f318 100644 --- a/src/components/dashboard/allocation-chart.tsx +++ b/src/components/dashboard/allocation-chart.tsx @@ -52,10 +52,12 @@ export default function AllocationChart({ data }: AllocationChartProps) { color: 'hsl(var(--foreground))', fontSize: '14px', }} - formatter={(value: number, name: string) => [ - value.toLocaleString(), - name, - ]} + formatter={(value) => { + const num = Number(value); + return [ + `¥${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, + ]; + }} />