From 9342e46aad207ede9a032d1115eccb8c9edaec33 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Tue, 28 Apr 2026 16:58:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E4=BC=98=E5=8C=96=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=88=86=E5=B8=83=E5=9B=BE=E8=A1=A8=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=8C=89=E5=B8=82=E5=9C=BA=E7=BB=B4=E5=BA=A6=E7=9A=84?= =?UTF-8?q?=E8=81=9A=E5=90=88=E5=B1=95=E7=A4=BA=E4=B8=8E=20Tooltip=20?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 6 ++ app/dashboard/page.tsx | 18 +--- src/actions/portfolio.ts | 70 ++++++++++++++++ src/components/dashboard/allocation-chart.tsx | 84 +++++++++++++------ 4 files changed, 135 insertions(+), 43 deletions(-) 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 })}`, - ]; - }} - /> + } />