diff --git a/Memory.md b/Memory.md
index f2a0ddc..9845d09 100644
--- a/Memory.md
+++ b/Memory.md
@@ -37,6 +37,12 @@
## 修复记录
- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 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)
- 重构盈亏计算引擎,支持已实现盈亏统计:交易按时间正序处理,SELL 时基于当时平均成本计算该笔卖出的利润并累加至 `realizedPnlCny`。
- 引入摊薄成本与平均成本双重指标:`avgCost = totalBuyCost / totalBuyQuantity`(平均成本);`dilutedCost = (totalBuyCost - realizedPnlCny) / currentQuantity`(摊薄成本,已实现盈亏会摊低持仓成本)。
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index cf1b801..fbe4db7 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -5,17 +5,8 @@ import AllocationChart from '@/components/dashboard/allocation-chart';
import { SyncButton } from '@/components/assets/sync-button';
import Big from 'big.js';
-const CHART_COLORS = [
- '#3b82f6',
- '#8b5cf6',
- '#10b981',
- '#f59e0b',
- '#ef4444',
- '#06b6d4',
-];
-
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 formattedTotalPnl = formatAmount(totalPnlCny);
@@ -23,11 +14,6 @@ export default async function DashboardPage() {
const totalPnlIsPositive = new Big(totalPnlCny).gte(0);
const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0);
- const displayChartData = chartData.map((item) => ({
- ...item,
- value: Number(item.value),
- }));
-
return (
@@ -168,7 +154,7 @@ export default async function DashboardPage() {
资产分布
-
+
diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts
index 0789612..d01054c 100644
--- a/src/actions/portfolio.ts
+++ b/src/actions/portfolio.ts
@@ -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
= {
+ '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 {
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 {
quantity: Big;
baseCurrency: string;
latestPrice: string;
+ exchange: string;
// 累计买入指标
totalBuyCostCny: Big;
totalBuyCostNative: Big;
@@ -147,6 +174,7 @@ export async function getPortfolioPositions(): Promise {
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 {
avgCost: avgCost.toString(),
dilutedCost: dilutedCost.toString(),
holdingDays,
+ exchange: holding.exchange,
});
}
@@ -307,11 +336,52 @@ export async function getPortfolioSummary() {
][index % 6],
}));
+ // 按市场维度聚合资产分布
+ const marketMap = new Map();
+
+ 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,
};
}
diff --git a/src/components/dashboard/allocation-chart.tsx b/src/components/dashboard/allocation-chart.tsx
index 292f318..b2bc542 100644
--- a/src/components/dashboard/allocation-chart.tsx
+++ b/src/components/dashboard/allocation-chart.tsx
@@ -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 {
+ 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 (
+
+
+
+ {data.market}
+
+
+ 市值
+
+ ¥{data.totalCnyValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+
+ 占比
+
+ {data.percentage.toFixed(1)}%
+
+
+
+ );
+ }
+ 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) => (
|
))}
- {
- const num = Number(value);
- return [
- `¥${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
- ];
- }}
- />
+ } />