feat(dashboard): 构建基于 Big.js 的持仓聚合引擎与总览卡片

This commit is contained in:
kennethcheng 2026-04-27 22:52:15 +08:00
parent 978d8a591e
commit 796889754e
2 changed files with 116 additions and 9 deletions

View File

@ -1,14 +1,53 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getPortfolioPositions } from '@/actions/portfolio';
import { formatQuantity } from '@/lib/formatters';
export default async function DashboardPage() {
const positions = await getPortfolioPositions();
export default function DashboardPage() {
return (
<Card className="max-w-md">
<CardHeader>
<CardTitle> Omniledger</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold"> Omniledger</h1>
<p className="text-muted-foreground"></p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{positions.length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground"></p>
</CardContent>
</Card>
) : (
positions.map((pos) => (
<Card key={pos.assetId}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{pos.symbol}</span>
<span className="text-sm font-normal text-muted-foreground">
{pos.type}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{formatQuantity(pos.quantity, pos.type)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{pos.baseCurrency}</span>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}
}

68
src/actions/portfolio.ts Normal file
View File

@ -0,0 +1,68 @@
'use server';
import { db } from '@/db';
import { transactions, assets } from '@/db/schema';
import Big from 'big.js';
import { desc } from 'drizzle-orm';
export async function getPortfolioPositions() {
const allTransactions = await db
.select({
txType: transactions.txType,
quantity: transactions.quantity,
assetId: transactions.assetId,
assetSymbol: assets.symbol,
assetType: assets.type,
assetBaseCurrency: assets.baseCurrency,
})
.from(transactions)
.leftJoin(assets, assets.id.eq(transactions.assetId))
.orderBy(desc(transactions.executedAt));
const holdings = new Map<string, {
assetId: string;
symbol: string;
type: string;
quantity: Big;
baseCurrency: string;
}>();
for (const tx of allTransactions) {
if (!tx.assetId) continue;
const existing = holdings.get(tx.assetId);
if (!existing) {
holdings.set(tx.assetId, {
assetId: tx.assetId,
symbol: tx.assetSymbol || tx.assetId,
type: tx.assetType || 'CASH',
quantity: new Big('0'),
baseCurrency: tx.assetBaseCurrency || '',
});
}
const holding = holdings.get(tx.assetId)!;
if (tx.txType === 'BUY' || tx.txType === 'AIRDROP') {
holding.quantity = holding.quantity.plus(tx.quantity);
} else if (tx.txType === 'SELL') {
holding.quantity = holding.quantity.minus(tx.quantity);
} else if (tx.txType === 'DIVIDEND') {
holding.quantity = holding.quantity.plus(tx.quantity);
}
}
const result = [];
for (const [_, holding] of holdings) {
if (holding.quantity.lte(0)) continue;
result.push({
assetId: holding.assetId,
symbol: holding.symbol,
type: holding.type,
quantity: holding.quantity.toString(),
baseCurrency: holding.baseCurrency,
});
}
return result;
}