feat(ledger): 引入 latestPrice 字段与历史成本追踪,实装 P&L 盈亏计算引擎
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user