Compare commits
2 Commits
5a056a238c
...
89b40a72bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 89b40a72bb | |||
| 8b76ec9a6d |
17
Memory.md
17
Memory.md
@ -401,3 +401,20 @@
|
||||
- **根因分析**:在 `src/components/dashboard/net-worth-chart.tsx`(Client Component)中,净值走势图的投入本金曲线和 Tooltip 需要读取经过汇率折算后的法币本金字段(`totalCostCny`),而非原币种或未经折算的字段。
|
||||
- **数据链路验证**:从数据库 `portfolio_snapshots.total_cost_cny` → Drizzle ORM 映射为 `totalCostCny` → `getSnapshots()` 返回 → `page.tsx` 的 `loadSnapshots()` 中计算 `totalCostCny = totalCnyValue - totalPnlCny` → 通过 props 传入 `NetWorthChart` → `chartData` 映射为 `totalCostCny` → `<Area dataKey="totalCostCny">` 渲染 + `CustomTooltip` 从 `payload[0].payload.totalCostCny` 读取。
|
||||
- **修复验证**:`Snapshot` 接口定义 `totalValueCny` / `totalCostCny`,`chartData` 映射使用 `totalValueCny` / `totalCostCny`(均 `parseFloat`),`<Area>` 的 `dataKey` 分别为 `totalValueCny` 和 `totalCostCny`,`CustomTooltip` 从 `data.totalValueCny` / `data.totalCostCny` 解构计算净盈亏。全链路字段名严格一致,确保投入本金曲线显示真实的累计投入成本(CNY 折算后)。
|
||||
|
||||
## 剔除 page.tsx 中违规的 `本金 = 市值 - 盈亏` 反向派生逻辑,确立 `盈亏 = 市值 - 本金` 的顺向金融计算流,彻底修复了走势图本金显示被旧 PnL 污染的架构 Bug (Task 81)
|
||||
- **根因分析**:在 `app/dashboard/page.tsx` 的 `loadSnapshots()` 函数中(第 178 行),今日快照的 `totalCostCny` 被错误地通过 `new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny)).toString()` 反向推导得出。这导致:1) 本金被旧 PnL 数据污染,失去底层真实性;2) 违反了金融计算中"本金优先"的架构红线。
|
||||
- **架构红线**:绝对禁止反推本金。本金 (`totalCostCny`) 必须直接读取数据库 snapshot 表或从 position 层级的 `totalCostCny` 字段正和累加,绝不允许做任何加减法。
|
||||
- **具体修复**:
|
||||
- 在 `src/actions/portfolio.ts` 的 `getPortfolioSummary()` 函数中新增 `totalCostCny` 的逐项累加计算:`totalCostCny = sum(pos.totalCostCny)`,并在返回值中暴露 `totalCostCny` 字段。
|
||||
- 在 `app/dashboard/page.tsx` 的 `loadSnapshots()` 函数中,删除 `new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny))` 的反向推导代码,改为直接使用 `summary.totalCostCny` 作为快照的本金值。
|
||||
- 净盈亏在 `NetWorthChart` 的 `CustomTooltip` 中通过 `pnl = totalValue - totalCost` 顺向派生,符合"盈亏 = 市值 - 本金"的金融计算规范。
|
||||
- **验收标准**:鼠标悬浮在图表上,投入本金显示底层原汁原味的成本值(如 ¥5094.59),净盈亏自动修正为真实值(如 +¥472.12)。
|
||||
|
||||
## 暴力重构 NetWorthChart 数据绑定逻辑,添加对后端字段名 (snake_case vs camelCase) 的强力兼容,彻底消除前端 Tooltip 的旧账残影 (Task 82)
|
||||
- **根因分析**:`src/components/dashboard/net-worth-chart.tsx` 的 `CustomTooltip` 和 `chartData` 映射层仅依赖驼峰字段 (`totalCostCny`),但后端 Drizzle ORM 返回的原始数据可能包含蛇形字段 (`total_cost_cny`),导致 Tooltip 中本金显示错误值(如 704 而非真实的 5094)。
|
||||
- **强制调试日志**:在组件入口注入 `console.log("【CHART DATA DEBUG】", snapshots[0])`,通过浏览器控制台 F12 直接查看原始数据结构,作为排查字段映射问题的终极武器。
|
||||
- **数据映射层蛇形/驼峰双重兼容**:在 `chartData` 的 `map` 函数中,采用 `parseFloat(s.totalCostCny) || parseFloat(s.total_cost_cny || 0)` 的强制 fallback 逻辑,确保无论后端返回哪种命名风格都能正确解析。
|
||||
- **Tooltip 防御性解构**:`CustomTooltip` 中的值读取改为 `Number(dataNode.totalValueCny || dataNode._raw?.totalValueCny || 0) || 0`,通过 `_raw` 快照兜底读取,确保 Tooltip 永远能拿到本金和现值的真实数据。
|
||||
- **Snapshot 接口扩展**:新增 `total_value_cny?: string` 和 `total_cost_cny?: string` 可选字段,`ChartDatum` 接口新增 `_raw: Snapshot` 字段用于 Tooltip 层 fallback。
|
||||
- **验收标准**:控制台 `【CHART DATA DEBUG】` 打印出带真实本金(如 5094)的字段;Tooltip 中投入本金显示真实法币数字,彻底消除 704 旧账残影。
|
||||
@ -171,20 +171,19 @@ export default function DashboardPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSnapshots() {
|
||||
await recordDailySnapshot();
|
||||
const summary = await getPortfolioSummary();
|
||||
await recordDailySnapshot();
|
||||
const data = await getSnapshots();
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const totalCostCny = new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny)).toString();
|
||||
const lastSnapshot = data[data.length - 1];
|
||||
if (lastSnapshot && lastSnapshot.date === todayStr) {
|
||||
lastSnapshot.totalValueCny = summary.totalCnyValue;
|
||||
lastSnapshot.totalCostCny = totalCostCny;
|
||||
lastSnapshot.totalCostCny = summary.totalCostCny;
|
||||
} else {
|
||||
data.push({
|
||||
date: todayStr,
|
||||
totalValueCny: summary.totalCnyValue,
|
||||
totalCostCny,
|
||||
totalCostCny: summary.totalCostCny,
|
||||
});
|
||||
}
|
||||
setSnapshots(data);
|
||||
|
||||
@ -468,11 +468,13 @@ export async function getPortfolioSummary(includeCleared: boolean = false) {
|
||||
let totalCnyValue = new Big('0');
|
||||
let totalPnlCny = new Big('0');
|
||||
let totalFloatingPnlCny = new Big('0');
|
||||
let totalCostCny = new Big('0');
|
||||
|
||||
for (const pos of positions) {
|
||||
totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
|
||||
totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0'));
|
||||
totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0'));
|
||||
totalCostCny = totalCostCny.plus(new Big(pos.totalCostCny || '0'));
|
||||
}
|
||||
|
||||
const chartData = positions.map((pos, index) => ({
|
||||
@ -531,6 +533,7 @@ export async function getPortfolioSummary(includeCleared: boolean = false) {
|
||||
return {
|
||||
positions,
|
||||
totalCnyValue: totalCnyValue.toString(),
|
||||
totalCostCny: totalCostCny.toString(),
|
||||
totalPnlCny: totalPnlCny.toString(),
|
||||
unrealizedPnlCny: totalFloatingPnlCny.toString(),
|
||||
chartData,
|
||||
|
||||
@ -6,19 +6,30 @@ interface Snapshot {
|
||||
date: string;
|
||||
totalValueCny: string;
|
||||
totalCostCny: string;
|
||||
total_value_cny?: string;
|
||||
total_cost_cny?: string;
|
||||
}
|
||||
|
||||
interface ChartDatum {
|
||||
date: string;
|
||||
totalValueCny: number;
|
||||
totalCostCny: number;
|
||||
_raw: Snapshot;
|
||||
}
|
||||
|
||||
interface NetWorthChartProps {
|
||||
snapshots: Snapshot[];
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: string; payload: Snapshot }>; label?: string }) {
|
||||
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: string; payload: any }>; label?: string }) {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const totalValue = parseFloat(data.totalValueCny);
|
||||
const totalCost = parseFloat(data.totalCostCny);
|
||||
const pnl = totalValue - totalCost;
|
||||
const pnlPercent = totalCost > 0 ? (pnl / totalCost) * 100 : 0;
|
||||
const dataNode = payload[0].payload;
|
||||
|
||||
const value = Number(dataNode.totalValueCny || dataNode._raw?.totalValueCny || 0) || 0;
|
||||
const cost = Number(dataNode.totalCostCny || dataNode._raw?.totalCostCny || 0) || 0;
|
||||
|
||||
const pnl = value - cost;
|
||||
const pnlPercent = cost > 0 ? (pnl / cost) * 100 : 0;
|
||||
const isPositive = pnl >= 0;
|
||||
|
||||
const formattedDate = (label || '').replace(/-/g, '/');
|
||||
@ -42,7 +53,7 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?:
|
||||
总市值
|
||||
</span>
|
||||
<span style={{ fontWeight: '700', color: '#f59e0b' }}>
|
||||
¥{totalValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
¥{value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '24px', marginBottom: '4px' }}>
|
||||
@ -51,7 +62,7 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?:
|
||||
投入本金
|
||||
</span>
|
||||
<span style={{ fontWeight: '600', color: 'hsl(var(--muted-foreground))' }}>
|
||||
¥{totalCost.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
¥{cost.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '24px', paddingTop: '6px', borderTop: '1px solid hsl(var(--border))' }}>
|
||||
@ -79,11 +90,18 @@ export default function NetWorthChart({ snapshots }: NetWorthChartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = snapshots.map(s => ({
|
||||
date: s.date.replace(/-/g, '/'),
|
||||
totalValueCny: parseFloat(s.totalValueCny),
|
||||
totalCostCny: parseFloat(s.totalCostCny),
|
||||
}));
|
||||
console.log("【CHART DATA DEBUG】", snapshots[0]);
|
||||
|
||||
const chartData = snapshots.map(s => {
|
||||
const totalValueCny = parseFloat(s.totalValueCny) || parseFloat((s as any).total_value_cny || 0);
|
||||
const totalCostCny = parseFloat(s.totalCostCny) || parseFloat((s as any).total_cost_cny || 0);
|
||||
return {
|
||||
date: s.date.replace(/-/g, '/'),
|
||||
totalValueCny,
|
||||
totalCostCny,
|
||||
_raw: s,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-[320px] w-full">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user