feat(dashboard): 优化资产分布图表,实现按市场维度的聚合展示与 Tooltip 交互增强
This commit is contained in:
@@ -24,6 +24,7 @@ interface Position {
|
||||
avgCost: string;
|
||||
dilutedCost: string;
|
||||
holdingDays: number;
|
||||
exchange: string;
|
||||
}
|
||||
|
||||
interface RawRate {
|
||||
@@ -80,6 +81,30 @@ function calculateCnyValueFromPrice(
|
||||
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 {
|
||||
const now = new Date();
|
||||
const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||
@@ -102,6 +127,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
assetType: assets.type,
|
||||
assetBaseCurrency: assets.baseCurrency,
|
||||
assetLatestPrice: assets.latestPrice,
|
||||
assetExchange: assets.exchange,
|
||||
executedAt: transactions.executedAt,
|
||||
})
|
||||
.from(transactions)
|
||||
@@ -124,6 +150,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
quantity: Big;
|
||||
baseCurrency: string;
|
||||
latestPrice: string;
|
||||
exchange: string;
|
||||
// 累计买入指标
|
||||
totalBuyCostCny: Big;
|
||||
totalBuyCostNative: Big;
|
||||
@@ -147,6 +174,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
quantity: new Big('0'),
|
||||
baseCurrency: tx.assetBaseCurrency || '',
|
||||
latestPrice: tx.assetLatestPrice || '0',
|
||||
exchange: tx.assetExchange || 'US',
|
||||
totalBuyCostCny: new Big('0'),
|
||||
totalBuyCostNative: new Big('0'),
|
||||
totalBuyQuantity: new Big('0'),
|
||||
@@ -266,6 +294,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
avgCost: avgCost.toString(),
|
||||
dilutedCost: dilutedCost.toString(),
|
||||
holdingDays,
|
||||
exchange: holding.exchange,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,11 +336,52 @@ export async function getPortfolioSummary() {
|
||||
][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 {
|
||||
positions,
|
||||
totalCnyValue: totalCnyValue.toString(),
|
||||
totalPnlCny: totalPnlCny.toString(),
|
||||
unrealizedPnlCny: unrealizedPnlCny.toString(),
|
||||
chartData,
|
||||
marketAllocation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, TooltipProps } from 'recharts';
|
||||
|
||||
interface AllocationChartProps {
|
||||
data: { name: string; value: number; fill: string }[];
|
||||
data: {
|
||||
market: string;
|
||||
name: string;
|
||||
totalCnyValue: number;
|
||||
percentage: number;
|
||||
fill: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#3b82f6',
|
||||
'#8b5cf6',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#06b6d4',
|
||||
];
|
||||
interface CustomTooltipProps extends TooltipProps<number, string> {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: { market: string; totalCnyValue: number; percentage: number; fill: string } }>;
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload }: CustomTooltipProps) {
|
||||
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) {
|
||||
if (!data || data.length === 0) {
|
||||
@@ -35,30 +79,16 @@ export default function AllocationChart({ data }: AllocationChartProps) {
|
||||
innerRadius={60}
|
||||
outerRadius={110}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
dataKey="totalCnyValue"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.fill || CHART_COLORS[index % CHART_COLORS.length]}
|
||||
fill={entry.fill}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
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 })}`,
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user