Compare commits

...

5 Commits

4 changed files with 95 additions and 14 deletions

View File

@ -78,6 +78,12 @@
- 利用 `Big.js` 剥离了流水明细中无意义的尾随零,提升了高精度数据的可读性。 - 利用 `Big.js` 剥离了流水明细中无意义的尾随零,提升了高精度数据的可读性。
- 在 `app/dashboard/page.tsx` 的流水明細子表格中,将 `tx.quantity`、`tx.price`、`tx.fee` 的渲染逻辑改为 `new Big(value).toString()`,安全剥离因数据库 `numeric(36,18)` 配置导致的如 `0.041000000000000000` 这类冗余尾随零。 - 在 `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 幣種算法重構 (Task 38)
- 重構底層盈虧引擎,全面轉向 Native 原生幣種計算,新增浮動/累計盈虧及百分比指標。 - 重構底層盈虧引擎,全面轉向 Native 原生幣種計算,新增浮動/累計盈虧及百分比指標。
- 徹底分離 Native 與 CNY 計算:單隻股票的成本與盈虧全部改用 Native (原幣種) 進行計算。 - 徹底分離 Native 與 CNY 計算:單隻股票的成本與盈虧全部改用 Native (原幣種) 進行計算。

View File

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

View File

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

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useTransition } from 'react'; import { useState, useTransition, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@ -32,7 +32,23 @@ import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { updateTransaction } from '@/actions/transaction'; 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({ const updateTransactionSchema = z.object({
assetId: z.string(),
txType: z.string(),
quantity: z.string().regex(/^-?\d+(\.\d+)?$/, '数量必须是数字'), quantity: z.string().regex(/^-?\d+(\.\d+)?$/, '数量必须是数字'),
price: z.string().regex(/^-?\d+(\.\d+)?$/, '价格必须是数字'), price: z.string().regex(/^-?\d+(\.\d+)?$/, '价格必须是数字'),
fee: z.string().regex(/^-?\d+(\.\d+)?$/, '手续费必须是数字').default('0'), fee: z.string().regex(/^-?\d+(\.\d+)?$/, '手续费必须是数字').default('0'),
@ -45,8 +61,10 @@ type UpdateForm = z.infer<typeof updateTransactionSchema>;
interface UpdateTransactionDialogProps { interface UpdateTransactionDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
assets?: Asset[];
transaction: { transaction: {
id: string; id: string;
assetId: string;
txType: string; txType: string;
quantity: string; quantity: string;
price: string; price: string;
@ -60,6 +78,7 @@ interface UpdateTransactionDialogProps {
export function UpdateTransactionDialog({ export function UpdateTransactionDialog({
open, open,
onOpenChange, onOpenChange,
assets,
transaction, transaction,
onSuccess, onSuccess,
}: UpdateTransactionDialogProps) { }: UpdateTransactionDialogProps) {
@ -68,6 +87,8 @@ export function UpdateTransactionDialog({
const form = useForm<UpdateForm>({ const form = useForm<UpdateForm>({
resolver: zodResolver(updateTransactionSchema), resolver: zodResolver(updateTransactionSchema),
defaultValues: { defaultValues: {
assetId: '',
txType: '',
quantity: '', quantity: '',
price: '', price: '',
fee: '0', fee: '0',
@ -76,9 +97,11 @@ export function UpdateTransactionDialog({
}, },
}); });
useState(() => { useEffect(() => {
if (transaction && open) { if (transaction && open) {
form.reset({ form.reset({
assetId: transaction.assetId,
txType: transaction.txType,
quantity: transaction.quantity.toString(), quantity: transaction.quantity.toString(),
price: transaction.price.toString(), price: transaction.price.toString(),
fee: transaction.fee.toString(), fee: transaction.fee.toString(),
@ -88,7 +111,7 @@ export function UpdateTransactionDialog({
: '', : '',
}); });
} }
}); }, [transaction, open, form]);
function handleSubmit(values: UpdateForm) { function handleSubmit(values: UpdateForm) {
if (!transaction) return; if (!transaction) return;
@ -120,6 +143,56 @@ export function UpdateTransactionDialog({
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <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"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}