Compare commits

...

5 Commits

4 changed files with 95 additions and 14 deletions

View File

@ -78,6 +78,12 @@
- 利用 `Big.js` 剥离了流水明细中无意义的尾随零,提升了高精度数据的可读性。
- 在 `app/dashboard/page.tsx` 的流水明細子表格中,将 `tx.quantity`、`tx.price`、`tx.fee` 的渲染逻辑改为 `new Big(value).toString()`,安全剥离因数据库 `numeric(36,18)` 配置导致的如 `0.041000000000000000` 这类冗余尾随零。
## 盈亏红绿视觉规范 (Task 42c)
- 依据中文金融习惯(红涨绿跌),规范了盈亏数值的颜色与正负号显示。
- 移除了 `formatPnl()` 函数及概览行内硬编码拼接的 `+` 号前缀,正收益直接展示数值,负收益保留原生 `-` 号。
- 统一颜色逻辑:值 `> 0` 应用 `text-red-500`(红色),值 `< 0` 应用 `text-green-500`(绿色),值 `=== 0` 使用默认文字颜色。
- 括号内的百分比同步遵循相同逻辑,格式如 `$2447.48 (114.20%)`
## 持倉引擎 Native 幣種算法重構 (Task 38)
- 重構底層盈虧引擎,全面轉向 Native 原生幣種計算,新增浮動/累計盈虧及百分比指標。
- 徹底分離 Native 與 CNY 計算:單隻股票的成本與盈虧全部改用 Native (原幣種) 進行計算。

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useTransition } from 'react';
import React, { useState, useEffect, useTransition } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
@ -57,8 +57,7 @@ function formatPnl(value: string, percent: string, baseCurrency: string): { text
const symbol = getCurrencySymbol(baseCurrency);
const absValue = new Big(value).abs().toFixed(2);
const absPercent = new Big(percent).abs().toFixed(2);
const sign = isPositive ? '+' : '';
const text = `${sign}${symbol}${absValue} (${sign}${absPercent}%)`;
const text = `${symbol}${isPositive ? '' : '-'}${absValue} (${isPositive ? '' : '-'}${absPercent}%)`;
const className = isPositive ? 'text-red-500' : 'text-green-500';
return { text, className };
}
@ -157,14 +156,14 @@ export default function DashboardPage() {
<div className="mt-3 flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<span className={`text-lg font-semibold ${unrealizedIsPositive ? 'text-green-500' : 'text-red-500'}`}>
{unrealizedIsPositive ? '+' : ''}{formattedUnrealized}
<span className={`text-lg font-semibold ${unrealizedIsPositive ? 'text-red-500' : 'text-green-500'}`}>
{formattedUnrealized}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<span className={`text-lg font-semibold ${totalPnlIsPositive ? 'text-green-500' : 'text-red-500'}`}>
{totalPnlIsPositive ? '+' : ''}{formattedTotalPnl}
<span className={`text-lg font-semibold ${totalPnlIsPositive ? 'text-red-500' : 'text-green-500'}`}>
{formattedTotalPnl}
</span>
</div>
</div>
@ -215,8 +214,8 @@ export default function DashboardPage() {
const isExpanded = !!expandedIds[pos.assetId];
return (
<>
<TableRow key={pos.assetId} className="cursor-pointer" onClick={() => toggleExpand(pos.assetId)}>
<React.Fragment key={pos.assetId}>
<TableRow className="cursor-pointer" onClick={() => toggleExpand(pos.assetId)}>
<TableCell>
<div className="flex flex-col">
<span className="font-semibold">{pos.name || pos.symbol}</span>
@ -362,7 +361,7 @@ export default function DashboardPage() {
</TableCell>
</TableRow>
)}
</>
</React.Fragment>
);
})}
</TableBody>
@ -390,6 +389,7 @@ export default function DashboardPage() {
<UpdateTransactionDialog
open={!!updateTarget}
onOpenChange={(open) => !open && setUpdateTarget(null)}
assets={assets}
transaction={updateTarget}
onSuccess={handleUpdateSubmit}
/>

View File

@ -37,6 +37,7 @@ interface Position {
floatingPnlPercent: string;
cumulativePnlNative: string;
cumulativePnlPercent: string;
latestPrice: string;
transactions: TransactionRecord[];
}
@ -399,6 +400,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
floatingPnlPercent: floatingPnlPercent.toString(),
cumulativePnlNative: cumulativePnlNative.toString(),
cumulativePnlPercent: cumulativePnlPercent.toString(),
latestPrice: holding.latestPrice,
transactions: holding.transactions,
});
}
@ -430,7 +432,7 @@ export async function getPortfolioSummary() {
const chartData = positions.map((pos, index) => ({
name: pos.symbol,
value: new Big(pos.cnyValue),
value: new Big(pos.cnyValue).toNumber(),
fill: [
'#3b82f6',
'#8b5cf6',

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useTransition } from 'react';
import { useState, useTransition, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@ -32,7 +32,23 @@ import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { updateTransaction } from '@/actions/transaction';
const txTypeLabels: Record<string, string> = {
BUY: '买入 (BUY)',
SELL: '卖出 (SELL)',
DIVIDEND: '分红 (DIVIDEND)',
AIRDROP: '空投 (AIRDROP)',
FEE: '手续费 (FEE)',
};
interface Asset {
id: string;
symbol: string;
name: string | null;
}
const updateTransactionSchema = z.object({
assetId: z.string(),
txType: z.string(),
quantity: z.string().regex(/^-?\d+(\.\d+)?$/, '数量必须是数字'),
price: z.string().regex(/^-?\d+(\.\d+)?$/, '价格必须是数字'),
fee: z.string().regex(/^-?\d+(\.\d+)?$/, '手续费必须是数字').default('0'),
@ -45,8 +61,10 @@ type UpdateForm = z.infer<typeof updateTransactionSchema>;
interface UpdateTransactionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
assets?: Asset[];
transaction: {
id: string;
assetId: string;
txType: string;
quantity: string;
price: string;
@ -60,6 +78,7 @@ interface UpdateTransactionDialogProps {
export function UpdateTransactionDialog({
open,
onOpenChange,
assets,
transaction,
onSuccess,
}: UpdateTransactionDialogProps) {
@ -68,6 +87,8 @@ export function UpdateTransactionDialog({
const form = useForm<UpdateForm>({
resolver: zodResolver(updateTransactionSchema),
defaultValues: {
assetId: '',
txType: '',
quantity: '',
price: '',
fee: '0',
@ -76,9 +97,11 @@ export function UpdateTransactionDialog({
},
});
useState(() => {
useEffect(() => {
if (transaction && open) {
form.reset({
assetId: transaction.assetId,
txType: transaction.txType,
quantity: transaction.quantity.toString(),
price: transaction.price.toString(),
fee: transaction.fee.toString(),
@ -88,7 +111,7 @@ export function UpdateTransactionDialog({
: '',
});
}
});
}, [transaction, open, form]);
function handleSubmit(values: UpdateForm) {
if (!transaction) return;
@ -120,6 +143,56 @@ export function UpdateTransactionDialog({
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="assetId"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select disabled>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择资产" />
</SelectTrigger>
</FormControl>
<SelectContent>
{assets?.map((asset) => (
<SelectItem key={asset.id} value={asset.id}>
{asset.symbol}{asset.name ? ` (${asset.name})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="txType"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select disabled>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择交易类型" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(txTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}