feat(dashboard): 接入汇率引擎,实现多资产交叉汇率折算与 CNY 统一计价看板
This commit is contained in:
parent
84b8dc3226
commit
27c3b76bba
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user