feat(ledger): 引入 latestPrice 字段与历史成本追踪,实装 P&L 盈亏计算引擎

This commit is contained in:
2026-04-28 00:57:33 +08:00
parent 27c3b76bba
commit b9186d4699
6 changed files with 241 additions and 101 deletions
+17 -10
View File
@@ -1,5 +1,6 @@
import { getAssets } from '@/actions/asset';
import { AddAssetDialog } from '@/components/assets/add-asset-dialog';
import { UpdatePriceDialog } from '@/components/assets/update-price-dialog';
import {
Table,
TableBody,
@@ -15,33 +16,35 @@ export default async function AssetsPage() {
const typeLabels: Record<string, string> = {
STOCK: '股票',
CRYPTO: '加密货币',
CASH: '金',
CRYPTO: '加密貨幣',
CASH: '金',
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<h1 className="text-2xl font-bold"></h1>
<AddAssetDialog />
</div>
<div className="rounded-md border">
<Table>
<TableCaption></TableCaption>
<TableCaption></TableCaption>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> (Latest Price)</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assets.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
"添加资产"
<TableCell colSpan={6} className="text-center text-muted-foreground">
"添加資產"
</TableCell>
</TableRow>
) : (
@@ -50,11 +53,15 @@ export default async function AssetsPage() {
<TableCell className="font-medium">{asset.symbol}</TableCell>
<TableCell>{typeLabels[asset.type] || asset.type}</TableCell>
<TableCell>{asset.baseCurrency}</TableCell>
<TableCell>{asset.latestPrice}</TableCell>
<TableCell>
{asset.createdAt
? new Date(asset.createdAt).toLocaleString('zh-CN')
: '-'}
</TableCell>
<TableCell>
<UpdatePriceDialog assetId={asset.id} currentPrice={asset.latestPrice || '0'} />
</TableCell>
</TableRow>
))
)}
+58 -31
View File
@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getPortfolioSummary } from '@/actions/portfolio';
import { formatQuantity, formatAmount } from '@/lib/formatters';
import AllocationChart from '@/components/dashboard/allocation-chart';
import Big from 'big.js';
const CHART_COLORS = [
'#3b82f6',
@@ -13,9 +14,11 @@ const CHART_COLORS = [
];
export default async function DashboardPage() {
const { positions, totalCnyValue, chartData } = await getPortfolioSummary();
const { positions, totalCnyValue, chartData, totalPnlCny } = await getPortfolioSummary();
const formattedTotal = formatAmount(totalCnyValue);
const formattedPnl = formatAmount(totalPnlCny);
const pnlIsPositive = new Big(totalPnlCny).gte(0);
const displayChartData = chartData.map((item) => ({
...item,
@@ -42,6 +45,12 @@ export default async function DashboardPage() {
(CNY)
</span>
</div>
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<span className={`text-lg font-semibold ${pnlIsPositive ? 'text-green-500' : 'text-red-500'}`}>
{pnlIsPositive ? '+' : ''}{formattedPnl}
</span>
</div>
</CardContent>
</Card>
@@ -53,38 +62,56 @@ export default async function DashboardPage() {
</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)}
positions.map((pos) => {
const posPnl = new Big(pos.pnlCny);
const posPnlPositive = posPnl.gte(0);
const formattedPosPnl = formatAmount(pos.pnlCny);
return (
<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 className="flex justify-between">
<span className="text-muted-foreground">CNY </span>
<span className="font-medium text-green-600">
¥{formatAmount(pos.cnyValue)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> (CNY)</span>
<span className="font-medium">
¥{formatAmount(pos.totalCostCny)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className={`font-semibold ${posPnlPositive ? 'text-green-500' : 'text-red-500'}`}>
{posPnlPositive ? '+' : ''}{formattedPosPnl}
</span>
</div>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{pos.baseCurrency}</span>
</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>
</CardContent>
</Card>
))
</CardContent>
</Card>
);
})
)}
</div>