diff --git a/Memory.md b/Memory.md index 9d0f68c..34d16a6 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,13 @@ # Omniledger 架构与开发记忆 (Memory) +## 新增持仓明细的已清仓资产显示开关(基于 1e-8 精度容差过滤),并实装注入 UTF-8 BOM 的客户端 CSV 导出功能 (Task 68) +- 在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中新增 `includeCleared: boolean = false` 参数,将清仓判定从 `quantity === 0` 升级为 `Big.js` 的 `1e-8` 精度容差过滤(`holding.quantity.gt('1e-8')`),杜绝浮点数灰尘资产被错误保留或隐藏。 +- 当 `!includeCleared` 时,自动过滤掉持仓量 ≤ 1e-8 的已清仓资产;当 `includeCleared` 为 true 时,已清仓资产会被返回,其 `marketValue` 和 `floatingPnl` 为 0,但**保留真实计算出的 `accumulatedPnl`(累计盈亏)字段**,确保历史盈亏数据不丢失。 +- 在 `app/dashboard/page.tsx` 的"持仓明细"卡片头部新增 Checkbox(显示"👁 显示历史持仓")和 Button("📥 导出 CSV"),Checkbox 切换时基于 `Big(pos.quantity).gt('1e-8')` 在前端动态过滤列表,无需额外请求。 +- 编写 `exportToCSV()` 客户端导出函数:定义中文字段表头(资产名称、代码、持仓量、成本价、现价、总市值、浮动盈亏、累计盈亏),将当前表格数据映射为 CSV 格式,**注入 `\uFEFF` UTF-8 BOM 头**并创建 Blob 触发下载,彻底解决 Excel 打开中文乱码问题。 +- 文件名格式为 `portfolio_details_YYYY-MM-DD.csv`,所有数值字段用双引号包裹防止逗号破坏 CSV 格式。 +- 同步更新 `getPortfolioSummary(includeCleared)` 和 `recordDailySnapshot()` 以传递参数。 + ## 修复腾讯行情接口 URL 拼接逻辑,剔除导致数据残缺的 s_ (简易版) 前缀,确保所有市场强制获取包含时间戳的全量报文 (Task 67) - 在 `app/api/cron/fetch-prices/route.ts` 的 `fetchStockPrice()` 函数与 `src/actions/market.ts` 的 `getTencentSymbol()` 函数中,将美股资产的前缀映射从 `'s_us'` 强制重构为 `'us'`。 - **根因分析:** 腾讯财经 gtimg 接口使用 `s_us` 前缀时返回的是"简易版"报文(仅 ~10 个字段),缺失 Index 30 的日期时间字段;使用 `us` 前缀时返回"全量版"报文(60+ 个字段),包含完整的交易时间戳 `2026-05-01 16:00:06`。 diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index d2d69f3..a3cd9ae 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -31,7 +31,7 @@ import { AddTransactionDialog } from '@/components/transactions/add-transaction- import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog'; import { deleteTransaction } from '@/actions/transaction'; import { importHistoricalPrices } from '@/actions/market'; -import { ChevronDown, ChevronUp, Plus, Edit3, Trash2, Upload } from 'lucide-react'; +import { ChevronDown, ChevronUp, Plus, Edit3, Trash2, Upload, Download, Eye } from 'lucide-react'; import Big from 'big.js'; const txTypeMap: Record = { @@ -55,6 +55,36 @@ function formatNative(value: string, baseCurrency: string): string { return `${symbol}${formatted}`; } +function exportToCSV(positions: any[]) { + const headers = ["资产名称", "代码", "持仓量", "成本价", "现价", "总市值", "浮动盈亏", "累计盈亏"]; + + const rows = positions.map(item => [ + item.name || item.symbol, + item.symbol, + item.quantity || '0', + new Big(item.avgCostNative || '0').toFixed(2), + item.latestPrice || '0', + new Big(item.marketValueNative || '0').toFixed(2), + new Big(item.floatingPnlNative || '0').toFixed(2), + new Big(item.cumulativePnlNative || '0').toFixed(2), + ]); + + const csvContent = [ + headers.join(","), + ...rows.map(e => e.map(val => `"${val}"`).join(",")) + ].join("\n"); + + const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", `portfolio_details_${new Date().toISOString().split('T')[0]}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + function formatPnl(value: string, percent: string, baseCurrency: string): { text: string; className: string } { const isPositive = new Big(value).gte(0); const symbol = getCurrencySymbol(baseCurrency); @@ -91,11 +121,21 @@ export default function DashboardPage() { const [importDialogOpen, setImportDialogOpen] = useState(false); const [importAssetId, setImportAssetId] = useState(''); const [importText, setImportText] = useState(''); + const [showCleared, setShowCleared] = useState(false); + const [positionsRaw, setPositionsRaw] = useState([]); + + useEffect(() => { + const filtered = showCleared + ? positionsRaw + : positionsRaw.filter(pos => new Big(pos.quantity || '0').gt('1e-8')); + setPositions(filtered); + }, [showCleared, positionsRaw]); useEffect(() => { async function loadData() { - const summary = await getPortfolioSummary(); + const summary = await getPortfolioSummary(true); const allAssets = await getAssets(); + setPositionsRaw(summary.positions); setPositions(summary.positions); setTotalCnyValue(summary.totalCnyValue); setTotalPnlCny(summary.totalPnlCny); @@ -271,8 +311,29 @@ export default function DashboardPage() { - + 持仓明细 +
+ + +
{positions.length === 0 ? ( diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 0f9f22a..6dcb8b8 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -173,7 +173,7 @@ function getTodayInShanghai(): Date { return new Date(utcDate.getTime() + shanghaiOffset); } -export async function getPortfolioPositions(): Promise { +export async function getPortfolioPositions(includeCleared: boolean = false): Promise { const allTransactions = await db .select({ id: transactions.id, @@ -334,11 +334,17 @@ export async function getPortfolioPositions(): Promise { const today = getTodayInShanghai(); const result: Position[] = []; + const CLEARED_QUANTITY_TOLERANCE = new Big('1e-8'); + for (const [_, holding] of holdings) { - if (holding.quantity.lte(0)) continue; + const hasPosition = holding.quantity.gt(CLEARED_QUANTITY_TOLERANCE); + + if (!hasPosition && !includeCleared) { + continue; + } const cnyValue = calculateCnyValueFromPrice( - holding.quantity, + hasPosition ? holding.quantity : new Big('0'), holding.latestPrice, holding.baseCurrency, rateMap @@ -442,8 +448,8 @@ export async function getPortfolioPositions(): Promise { return result; } -export async function getPortfolioSummary() { - const positions = await getPortfolioPositions(); +export async function getPortfolioSummary(includeCleared: boolean = false) { + const positions = await getPortfolioPositions(includeCleared); // 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果 let totalCnyValue = new Big('0'); diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index 2604857..fe681b6 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -24,7 +24,7 @@ function getTodayInShanghai(): string { } export async function recordDailySnapshot() { - const positions = await getPortfolioPositions(); + const positions = await getPortfolioPositions(false); // 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny const totalValueCny = positions.reduce(