feat(dashboard): 接入汇率引擎,实现多资产交叉汇率折算与 CNY 统一计价看板

This commit is contained in:
kennethcheng 2026-04-27 23:57:18 +08:00
parent 84b8dc3226
commit 27c3b76bba
3 changed files with 200 additions and 15 deletions

View File

@ -1,6 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getPortfolioPositions } from '@/actions/portfolio'; import { getPortfolioSummary } from '@/actions/portfolio';
import { formatQuantity } from '@/lib/formatters'; import { formatQuantity, formatAmount } from '@/lib/formatters';
import AllocationChart from '@/components/dashboard/allocation-chart'; import AllocationChart from '@/components/dashboard/allocation-chart';
const CHART_COLORS = [ const CHART_COLORS = [
@ -13,12 +13,13 @@ const CHART_COLORS = [
]; ];
export default async function DashboardPage() { export default async function DashboardPage() {
const positions = await getPortfolioPositions(); const { positions, totalCnyValue, chartData } = await getPortfolioSummary();
const chartData = positions.map((pos, index) => ({ const formattedTotal = formatAmount(totalCnyValue);
name: pos.symbol,
value: Number(pos.quantity), const displayChartData = chartData.map((item) => ({
fill: CHART_COLORS[index % CHART_COLORS.length], ...item,
value: Number(item.value),
})); }));
return ( return (
@ -28,6 +29,22 @@ export default async function DashboardPage() {
<p className="text-muted-foreground"></p> <p className="text-muted-foreground"></p>
</div> </div>
<Card>
<CardContent className="pt-6 pb-6">
<div className="flex items-center gap-3">
<span className="text-2xl font-semibold text-muted-foreground">
¥
</span>
<span className="text-5xl font-bold">
{formattedTotal}
</span>
<span className="text-xl text-muted-foreground ml-2">
(CNY)
</span>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{positions.length === 0 ? ( {positions.length === 0 ? (
<Card> <Card>
@ -58,6 +75,12 @@ export default async function DashboardPage() {
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="font-medium">{pos.baseCurrency}</span> <span className="font-medium">{pos.baseCurrency}</span>
</div> </div>
<div className="flex justify-between">
<span className="text-muted-foreground">CNY </span>
<span className="font-medium text-green-600">
¥{formatAmount(pos.cnyValue)}
</span>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -70,7 +93,7 @@ export default async function DashboardPage() {
<CardTitle></CardTitle> <CardTitle></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AllocationChart data={chartData} /> <AllocationChart data={displayChartData} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,11 +1,80 @@
'use server'; 'use server';
import { db } from '@/db'; import { db } from '@/db';
import { transactions, assets } from '@/db/schema'; import { transactions, assets, exchangeRates } from '@/db/schema';
import Big from 'big.js'; import Big from 'big.js';
import { desc, eq } from 'drizzle-orm'; 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<string, string> {
const map = new Map<string, string>();
for (const r of rates) {
map.set(`${r.fromCurrency}_${r.toCurrency}`, r.rate);
}
return map;
}
function getRate(
rateMap: Map<string, string>,
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<string, string>,
cryptoPrices: Map<string, string>
): 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<Position[]> {
const allTransactions = await db const allTransactions = await db
.select({ .select({
txType: transactions.txType, txType: transactions.txType,
@ -14,6 +83,7 @@ export async function getPortfolioPositions() {
assetSymbol: assets.symbol, assetSymbol: assets.symbol,
assetType: assets.type, assetType: assets.type,
assetBaseCurrency: assets.baseCurrency, assetBaseCurrency: assets.baseCurrency,
assetPrice: transactions.price,
}) })
.from(transactions) .from(transactions)
.leftJoin(assets, eq(assets.id, transactions.assetId)) .leftJoin(assets, eq(assets.id, transactions.assetId))
@ -25,6 +95,7 @@ export async function getPortfolioPositions() {
type: string; type: string;
quantity: Big; quantity: Big;
baseCurrency: string; baseCurrency: string;
latestPrice: string;
}>(); }>();
for (const tx of allTransactions) { for (const tx of allTransactions) {
@ -38,6 +109,7 @@ export async function getPortfolioPositions() {
type: tx.assetType || 'CASH', type: tx.assetType || 'CASH',
quantity: new Big('0'), quantity: new Big('0'),
baseCurrency: tx.assetBaseCurrency || '', baseCurrency: tx.assetBaseCurrency || '',
latestPrice: tx.assetPrice || '0',
}); });
} }
@ -50,19 +122,107 @@ export async function getPortfolioPositions() {
} else if (tx.txType === 'DIVIDEND') { } else if (tx.txType === 'DIVIDEND') {
holding.quantity = holding.quantity.plus(tx.quantity); 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<string, string>();
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) { for (const [_, holding] of holdings) {
if (holding.quantity.lte(0)) continue; 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({ result.push({
assetId: holding.assetId, assetId: holding.assetId,
symbol: holding.symbol, symbol: holding.symbol,
type: holding.type, type: holding.type,
quantity: holding.quantity.toString(), quantity: holding.quantity.toString(),
baseCurrency: holding.baseCurrency, baseCurrency: holding.baseCurrency,
cnyValue: cnyValue.toString(),
}); });
} }
return result; 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,
};
}

View File

@ -52,10 +52,12 @@ export default function AllocationChart({ data }: AllocationChartProps) {
color: 'hsl(var(--foreground))', color: 'hsl(var(--foreground))',
fontSize: '14px', fontSize: '14px',
}} }}
formatter={(value: number, name: string) => [ formatter={(value) => {
value.toLocaleString(), const num = Number(value);
name, return [
]} `¥${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
];
}}
/> />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>