fix(ui): 修复价格尾零、增加币种下拉框,并重构持仓引擎实现原币种/本位币双轨制盈亏计算

This commit is contained in:
kennethcheng 2026-04-28 01:40:29 +08:00
parent 746be06840
commit 67ceb63b08
4 changed files with 48 additions and 12 deletions

View File

@ -10,6 +10,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import Big from 'big.js';
export default async function AssetsPage() { export default async function AssetsPage() {
const assets = await getAssets(); const assets = await getAssets();
@ -53,14 +54,14 @@ export default async function AssetsPage() {
<TableCell className="font-medium">{asset.symbol}</TableCell> <TableCell className="font-medium">{asset.symbol}</TableCell>
<TableCell>{typeLabels[asset.type] || asset.type}</TableCell> <TableCell>{typeLabels[asset.type] || asset.type}</TableCell>
<TableCell>{asset.baseCurrency}</TableCell> <TableCell>{asset.baseCurrency}</TableCell>
<TableCell>{asset.latestPrice}</TableCell> <TableCell>{asset.latestPrice ? new Big(asset.latestPrice).toString() : '-'}</TableCell>
<TableCell> <TableCell>
{asset.createdAt {asset.createdAt
? new Date(asset.createdAt).toLocaleString('zh-CN') ? new Date(asset.createdAt).toLocaleString('zh-CN')
: '-'} : '-'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<UpdatePriceDialog assetId={asset.id} currentPrice={asset.latestPrice || '0'} /> <UpdatePriceDialog assetId={asset.id} currentPrice={asset.latestPrice ? new Big(asset.latestPrice).toString() : '0'} />
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))

View File

@ -90,21 +90,33 @@ export default async function DashboardPage() {
<span className="font-medium">{pos.baseCurrency}</span> <span className="font-medium">{pos.baseCurrency}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">CNY </span> <span className="text-muted-foreground"> ({pos.baseCurrency})</span>
<span className="font-medium text-green-600"> <span className="font-medium">
¥{formatAmount(pos.cnyValue)} {formatAmount(pos.totalCostNative)}
</span> </span>
</div> </div>
<div className="flex justify-between"> {(() => {
const posPnlNative = new Big(pos.pnlNative);
const posPnlNativePositive = posPnlNative.gte(0);
return (
<div className="flex justify-between">
<span className="text-muted-foreground"> ({pos.baseCurrency})</span>
<span className={`font-semibold ${posPnlNativePositive ? 'text-green-500' : 'text-red-500'}`}>
{posPnlNativePositive ? '+' : ''}{formatAmount(pos.pnlNative)}
</span>
</div>
);
})()}
<div className="flex justify-between opacity-50">
<span className="text-muted-foreground"> (CNY)</span> <span className="text-muted-foreground"> (CNY)</span>
<span className="font-medium"> <span className="font-medium">
¥{formatAmount(pos.totalCostCny)} ¥{formatAmount(pos.totalCostCny)}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between opacity-50">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"> (CNY)</span>
<span className={`font-semibold ${posPnlPositive ? 'text-green-500' : 'text-red-500'}`}> <span className={`font-semibold ${posPnlPositive ? 'text-green-500' : 'text-red-500'}`}>
{posPnlPositive ? '+' : ''}{formattedPosPnl} {posPnlPositive ? '+' : ''}{formatAmount(pos.pnlCny)}
</span> </span>
</div> </div>
</div> </div>

View File

@ -14,6 +14,8 @@ interface Position {
cnyValue: string; cnyValue: string;
totalCostCny: string; totalCostCny: string;
pnlCny: string; pnlCny: string;
totalCostNative: string;
pnlNative: string;
} }
interface RawRate { interface RawRate {
@ -95,6 +97,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
baseCurrency: string; baseCurrency: string;
latestPrice: string; latestPrice: string;
totalCostCny: Big; totalCostCny: Big;
totalCostNative: Big;
}>(); }>();
for (const tx of allTransactions) { for (const tx of allTransactions) {
@ -110,6 +113,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
baseCurrency: tx.assetBaseCurrency || '', baseCurrency: tx.assetBaseCurrency || '',
latestPrice: tx.assetLatestPrice || '0', latestPrice: tx.assetLatestPrice || '0',
totalCostCny: new Big('0'), totalCostCny: new Big('0'),
totalCostNative: new Big('0'),
}); });
} }
@ -118,10 +122,15 @@ export async function getPortfolioPositions(): Promise<Position[]> {
if (tx.txType === 'BUY') { if (tx.txType === 'BUY') {
holding.quantity = holding.quantity.plus(new Big(tx.quantity)); holding.quantity = holding.quantity.plus(new Big(tx.quantity));
const costPerUnit = new Big(tx.quantity).times(new Big(tx.price)); const costPerUnit = new Big(tx.quantity).times(new Big(tx.price));
holding.totalCostNative = holding.totalCostNative.plus(costPerUnit);
const costCny = costPerUnit.times(new Big(tx.exchangeRate || '1')); const costCny = costPerUnit.times(new Big(tx.exchangeRate || '1'));
holding.totalCostCny = holding.totalCostCny.plus(costCny); holding.totalCostCny = holding.totalCostCny.plus(costCny);
} else if (tx.txType === 'SELL') { } else if (tx.txType === 'SELL') {
holding.quantity = holding.quantity.minus(new Big(tx.quantity)); holding.quantity = holding.quantity.minus(new Big(tx.quantity));
const sellCostPerUnit = new Big(tx.quantity).times(new Big(tx.price));
holding.totalCostNative = holding.totalCostNative.minus(sellCostPerUnit);
const sellCostCny = sellCostPerUnit.times(new Big(tx.exchangeRate || '1'));
holding.totalCostCny = holding.totalCostCny.minus(sellCostCny);
} else if (tx.txType === 'AIRDROP') { } else if (tx.txType === 'AIRDROP') {
holding.quantity = holding.quantity.plus(new Big(tx.quantity)); holding.quantity = holding.quantity.plus(new Big(tx.quantity));
} else if (tx.txType === 'DIVIDEND') { } else if (tx.txType === 'DIVIDEND') {
@ -160,6 +169,9 @@ export async function getPortfolioPositions(): Promise<Position[]> {
const pnlCny = cnyValue.minus(holding.totalCostCny); const pnlCny = cnyValue.minus(holding.totalCostCny);
totalPnlCny = totalPnlCny.plus(pnlCny); totalPnlCny = totalPnlCny.plus(pnlCny);
const currentNativeValue = new Big(holding.latestPrice).times(holding.quantity);
const pnlNative = currentNativeValue.minus(holding.totalCostNative);
result.push({ result.push({
assetId: holding.assetId, assetId: holding.assetId,
symbol: holding.symbol, symbol: holding.symbol,
@ -169,6 +181,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
cnyValue: cnyValue.toString(), cnyValue: cnyValue.toString(),
totalCostCny: holding.totalCostCny.toString(), totalCostCny: holding.totalCostCny.toString(),
pnlCny: pnlCny.toString(), pnlCny: pnlCny.toString(),
totalCostNative: holding.totalCostNative.toString(),
pnlNative: pnlNative.toString(),
}); });
} }

View File

@ -218,9 +218,18 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <Select onValueChange={field.onChange} defaultValue={field.value}>
<Input placeholder="USD" type="text" {...field} /> <FormControl>
</FormControl> <SelectTrigger>
<SelectValue placeholder="选择币种" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="CNY">CNY</SelectItem>
<SelectItem value="HKD">HKD</SelectItem>
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}