feat(dashboard): 优化资产分布图表,实现按市场维度的聚合展示与 Tooltip 交互增强
This commit is contained in:
parent
e093b94157
commit
9342e46aad
@ -37,6 +37,12 @@
|
|||||||
## 修复记录
|
## 修复记录
|
||||||
- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 Bug。在 `src/libs/utils.ts` 中新增 `nowInShanghai()`、`formatDateForDatetimeLocal()` 和 `parseDateTimeLocalToUTC_v2()` 函数,强制使用 `Asia/Shanghai` (UTC+8) 时区。所有 `new Date()` 调用(包括 Server Actions)均已对齐至东八区。日期选择器增加字符串格式校验与解析逻辑,避免 `Invalid time value` 错误。
|
- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 Bug。在 `src/libs/utils.ts` 中新增 `nowInShanghai()`、`formatDateForDatetimeLocal()` 和 `parseDateTimeLocalToUTC_v2()` 函数,强制使用 `Asia/Shanghai` (UTC+8) 时区。所有 `new Date()` 调用(包括 Server Actions)均已对齐至东八区。日期选择器增加字符串格式校验与解析逻辑,避免 `Invalid time value` 错误。
|
||||||
|
|
||||||
|
## 资产分布图表按市场维度升级 (Task 32)
|
||||||
|
- 优化资产分布图表,升级为按市场维度聚合展示,并增强了 Tooltip 的颜色指代与明细交互。
|
||||||
|
- 在 `portfolio.ts` 中新增 `getMarketFromExchange()` 函数,将资产按交易所归类为 A股 (SSE/SZSE)、港股 (HKEX)、美股 (US)、虚拟币 (CRYPTO)。
|
||||||
|
- 新增 `marketAllocation` 聚合数据,按市场维度汇总 `totalCnyValue` 并计算占比,自动过滤已清仓资产。
|
||||||
|
- 升级 `AllocationChart` 组件:数据源改为市场聚合数据,为各市场设定固定品牌色(A股红、港股黄、美股蓝、虚拟币绿),并自定义 Tooltip 渲染内容,悬停时清晰展示 `[市场名称] [对应颜色块] [CNY 金额] [占比%]`。
|
||||||
|
|
||||||
## 盈亏引擎重构 (Task 31)
|
## 盈亏引擎重构 (Task 31)
|
||||||
- 重构盈亏计算引擎,支持已实现盈亏统计:交易按时间正序处理,SELL 时基于当时平均成本计算该笔卖出的利润并累加至 `realizedPnlCny`。
|
- 重构盈亏计算引擎,支持已实现盈亏统计:交易按时间正序处理,SELL 时基于当时平均成本计算该笔卖出的利润并累加至 `realizedPnlCny`。
|
||||||
- 引入摊薄成本与平均成本双重指标:`avgCost = totalBuyCost / totalBuyQuantity`(平均成本);`dilutedCost = (totalBuyCost - realizedPnlCny) / currentQuantity`(摊薄成本,已实现盈亏会摊低持仓成本)。
|
- 引入摊薄成本与平均成本双重指标:`avgCost = totalBuyCost / totalBuyQuantity`(平均成本);`dilutedCost = (totalBuyCost - realizedPnlCny) / currentQuantity`(摊薄成本,已实现盈亏会摊低持仓成本)。
|
||||||
|
|||||||
@ -5,17 +5,8 @@ import AllocationChart from '@/components/dashboard/allocation-chart';
|
|||||||
import { SyncButton } from '@/components/assets/sync-button';
|
import { SyncButton } from '@/components/assets/sync-button';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
const CHART_COLORS = [
|
|
||||||
'#3b82f6',
|
|
||||||
'#8b5cf6',
|
|
||||||
'#10b981',
|
|
||||||
'#f59e0b',
|
|
||||||
'#ef4444',
|
|
||||||
'#06b6d4',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const { positions, totalCnyValue, chartData, totalPnlCny, unrealizedPnlCny } = await getPortfolioSummary();
|
const { positions, totalCnyValue, chartData, totalPnlCny, unrealizedPnlCny, marketAllocation } = await getPortfolioSummary();
|
||||||
|
|
||||||
const formattedTotal = formatAmount(totalCnyValue);
|
const formattedTotal = formatAmount(totalCnyValue);
|
||||||
const formattedTotalPnl = formatAmount(totalPnlCny);
|
const formattedTotalPnl = formatAmount(totalPnlCny);
|
||||||
@ -23,11 +14,6 @@ export default async function DashboardPage() {
|
|||||||
const totalPnlIsPositive = new Big(totalPnlCny).gte(0);
|
const totalPnlIsPositive = new Big(totalPnlCny).gte(0);
|
||||||
const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0);
|
const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0);
|
||||||
|
|
||||||
const displayChartData = chartData.map((item) => ({
|
|
||||||
...item,
|
|
||||||
value: Number(item.value),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@ -168,7 +154,7 @@ export default async function DashboardPage() {
|
|||||||
<CardTitle>资产分布</CardTitle>
|
<CardTitle>资产分布</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<AllocationChart data={displayChartData} />
|
<AllocationChart data={marketAllocation} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ interface Position {
|
|||||||
avgCost: string;
|
avgCost: string;
|
||||||
dilutedCost: string;
|
dilutedCost: string;
|
||||||
holdingDays: number;
|
holdingDays: number;
|
||||||
|
exchange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawRate {
|
interface RawRate {
|
||||||
@ -80,6 +81,30 @@ function calculateCnyValueFromPrice(
|
|||||||
return new Big('0');
|
return new Big('0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMarketFromExchange(exchange: string): string {
|
||||||
|
if (!exchange) return '未知';
|
||||||
|
const upper = exchange.toUpperCase();
|
||||||
|
if (upper === 'SSE' || upper === 'SZSE') return 'A股';
|
||||||
|
if (upper === 'HKEX') return '港股';
|
||||||
|
if (upper === 'CRYPTO') return '虚拟币';
|
||||||
|
return '美股';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKET_COLORS: Record<string, string> = {
|
||||||
|
'A股': '#ef4444',
|
||||||
|
'港股': '#f59e0b',
|
||||||
|
'美股': '#3b82f6',
|
||||||
|
'虚拟币': '#10b981',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MarketAllocation {
|
||||||
|
market: string;
|
||||||
|
name: string;
|
||||||
|
totalCnyValue: number;
|
||||||
|
percentage: number;
|
||||||
|
fill: string;
|
||||||
|
}
|
||||||
|
|
||||||
function getTodayInShanghai(): Date {
|
function getTodayInShanghai(): Date {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
|
const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||||
@ -102,6 +127,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
assetType: assets.type,
|
assetType: assets.type,
|
||||||
assetBaseCurrency: assets.baseCurrency,
|
assetBaseCurrency: assets.baseCurrency,
|
||||||
assetLatestPrice: assets.latestPrice,
|
assetLatestPrice: assets.latestPrice,
|
||||||
|
assetExchange: assets.exchange,
|
||||||
executedAt: transactions.executedAt,
|
executedAt: transactions.executedAt,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
@ -124,6 +150,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
quantity: Big;
|
quantity: Big;
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
latestPrice: string;
|
latestPrice: string;
|
||||||
|
exchange: string;
|
||||||
// 累计买入指标
|
// 累计买入指标
|
||||||
totalBuyCostCny: Big;
|
totalBuyCostCny: Big;
|
||||||
totalBuyCostNative: Big;
|
totalBuyCostNative: Big;
|
||||||
@ -147,6 +174,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
baseCurrency: tx.assetBaseCurrency || '',
|
baseCurrency: tx.assetBaseCurrency || '',
|
||||||
latestPrice: tx.assetLatestPrice || '0',
|
latestPrice: tx.assetLatestPrice || '0',
|
||||||
|
exchange: tx.assetExchange || 'US',
|
||||||
totalBuyCostCny: new Big('0'),
|
totalBuyCostCny: new Big('0'),
|
||||||
totalBuyCostNative: new Big('0'),
|
totalBuyCostNative: new Big('0'),
|
||||||
totalBuyQuantity: new Big('0'),
|
totalBuyQuantity: new Big('0'),
|
||||||
@ -266,6 +294,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
avgCost: avgCost.toString(),
|
avgCost: avgCost.toString(),
|
||||||
dilutedCost: dilutedCost.toString(),
|
dilutedCost: dilutedCost.toString(),
|
||||||
holdingDays,
|
holdingDays,
|
||||||
|
exchange: holding.exchange,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,11 +336,52 @@ export async function getPortfolioSummary() {
|
|||||||
][index % 6],
|
][index % 6],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 按市场维度聚合资产分布
|
||||||
|
const marketMap = new Map<string, {
|
||||||
|
market: string;
|
||||||
|
totalCnyValue: Big;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const pos of positions) {
|
||||||
|
const market = getMarketFromExchange(pos.exchange);
|
||||||
|
const existing = marketMap.get(market);
|
||||||
|
if (existing) {
|
||||||
|
existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.cnyValue));
|
||||||
|
} else {
|
||||||
|
marketMap.set(market, {
|
||||||
|
market,
|
||||||
|
totalCnyValue: new Big(pos.cnyValue),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketAllocation: MarketAllocation[] = [];
|
||||||
|
let grandTotal = new Big('0');
|
||||||
|
for (const [, data] of marketMap) {
|
||||||
|
grandTotal = grandTotal.plus(data.totalCnyValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, data] of marketMap) {
|
||||||
|
const percentage = grandTotal.gt(0)
|
||||||
|
? data.totalCnyValue.div(grandTotal).times(100)
|
||||||
|
: new Big('0');
|
||||||
|
marketAllocation.push({
|
||||||
|
market: data.market,
|
||||||
|
name: data.market,
|
||||||
|
totalCnyValue: Number(data.totalCnyValue.toString()),
|
||||||
|
percentage: Number(percentage.toString()),
|
||||||
|
fill: MARKET_COLORS[data.market] || '#6b7280',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
marketAllocation.sort((a, b) => b.totalCnyValue - a.totalCnyValue);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
positions,
|
positions,
|
||||||
totalCnyValue: totalCnyValue.toString(),
|
totalCnyValue: totalCnyValue.toString(),
|
||||||
totalPnlCny: totalPnlCny.toString(),
|
totalPnlCny: totalPnlCny.toString(),
|
||||||
unrealizedPnlCny: unrealizedPnlCny.toString(),
|
unrealizedPnlCny: unrealizedPnlCny.toString(),
|
||||||
chartData,
|
chartData,
|
||||||
|
marketAllocation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,63 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, TooltipProps } from 'recharts';
|
||||||
|
|
||||||
interface AllocationChartProps {
|
interface AllocationChartProps {
|
||||||
data: { name: string; value: number; fill: string }[];
|
data: {
|
||||||
|
market: string;
|
||||||
|
name: string;
|
||||||
|
totalCnyValue: number;
|
||||||
|
percentage: number;
|
||||||
|
fill: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHART_COLORS = [
|
interface CustomTooltipProps extends TooltipProps<number, string> {
|
||||||
'#3b82f6',
|
active?: boolean;
|
||||||
'#8b5cf6',
|
payload?: Array<{ payload: { market: string; totalCnyValue: number; percentage: number; fill: string } }>;
|
||||||
'#10b981',
|
}
|
||||||
'#f59e0b',
|
|
||||||
'#ef4444',
|
function CustomTooltip({ active, payload }: CustomTooltipProps) {
|
||||||
'#06b6d4',
|
if (active && payload && payload.length) {
|
||||||
];
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
borderColor: 'hsl(var(--border))',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '10px 14px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
backgroundColor: data.fill,
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<span style={{ fontWeight: '600', fontSize: '14px' }}>{data.market}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '20px', marginTop: '4px' }}>
|
||||||
|
<span style={{ color: 'hsl(var(--muted-foreground))' }}>市值</span>
|
||||||
|
<span style={{ fontWeight: '600' }}>
|
||||||
|
¥{data.totalCnyValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '20px', marginTop: '2px' }}>
|
||||||
|
<span style={{ color: 'hsl(var(--muted-foreground))' }}>占比</span>
|
||||||
|
<span style={{ fontWeight: '600' }}>
|
||||||
|
{data.percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AllocationChart({ data }: AllocationChartProps) {
|
export default function AllocationChart({ data }: AllocationChartProps) {
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
@ -35,30 +79,16 @@ export default function AllocationChart({ data }: AllocationChartProps) {
|
|||||||
innerRadius={60}
|
innerRadius={60}
|
||||||
outerRadius={110}
|
outerRadius={110}
|
||||||
paddingAngle={3}
|
paddingAngle={3}
|
||||||
dataKey="value"
|
dataKey="totalCnyValue"
|
||||||
>
|
>
|
||||||
{data.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={entry.fill || CHART_COLORS[index % CHART_COLORS.length]}
|
fill={entry.fill}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip content={<CustomTooltip />} />
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
|
||||||
borderColor: 'hsl(var(--border))',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: 'hsl(var(--foreground))',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
|
||||||
formatter={(value) => {
|
|
||||||
const num = Number(value);
|
|
||||||
return [
|
|
||||||
`¥${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
|
|
||||||
];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user