Compare commits
5 Commits
579b09841f
...
2395d792db
| Author | SHA1 | Date | |
|---|---|---|---|
| 2395d792db | |||
| a408cad494 | |||
| eaeb143190 | |||
| 7c2f464f2c | |||
| 1b947d563a |
@ -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 (原幣種) 進行計算。
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user