Compare commits

..

No commits in common. "89b40a72bb89f5794f33f0f92624040faf03edbe" and "5a056a238c18d171cb9cbc57c0d03de2f4a6af5e" have entirely different histories.

4 changed files with 18 additions and 55 deletions

View File

@ -401,20 +401,3 @@
- **根因分析**:在 `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 旧账残影。

View File

@ -171,19 +171,20 @@ export default function DashboardPage() {
useEffect(() => {
async function loadSnapshots() {
const summary = await getPortfolioSummary();
await recordDailySnapshot();
const summary = await getPortfolioSummary();
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 = summary.totalCostCny;
lastSnapshot.totalCostCny = totalCostCny;
} else {
data.push({
date: todayStr,
totalValueCny: summary.totalCnyValue,
totalCostCny: summary.totalCostCny,
totalCostCny,
});
}
setSnapshots(data);

View File

@ -468,13 +468,11 @@ 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) => ({
@ -533,7 +531,6 @@ export async function getPortfolioSummary(includeCleared: boolean = false) {
return {
positions,
totalCnyValue: totalCnyValue.toString(),
totalCostCny: totalCostCny.toString(),
totalPnlCny: totalPnlCny.toString(),
unrealizedPnlCny: totalFloatingPnlCny.toString(),
chartData,

View File

@ -6,30 +6,19 @@ 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: any }>; label?: string }) {
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: string; payload: Snapshot }>; label?: string }) {
if (active && payload && payload.length) {
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 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 isPositive = pnl >= 0;
const formattedDate = (label || '').replace(/-/g, '/');
@ -53,7 +42,7 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?:
</span>
<span style={{ fontWeight: '700', color: '#f59e0b' }}>
¥{value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
¥{totalValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '24px', marginBottom: '4px' }}>
@ -62,7 +51,7 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?:
</span>
<span style={{ fontWeight: '600', color: 'hsl(var(--muted-foreground))' }}>
¥{cost.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
¥{totalCost.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))' }}>
@ -90,18 +79,11 @@ export default function NetWorthChart({ snapshots }: NetWorthChartProps) {
);
}
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 {
const chartData = snapshots.map(s => ({
date: s.date.replace(/-/g, '/'),
totalValueCny,
totalCostCny,
_raw: s,
};
});
totalValueCny: parseFloat(s.totalValueCny),
totalCostCny: parseFloat(s.totalCostCny),
}));
return (
<div className="h-[320px] w-full">