feat(portfolio): 支持显示已清仓历史持仓,增加防乱码 CSV 导出功能
This commit is contained in:
parent
2570144112
commit
3e81c1dc5b
@ -1,5 +1,13 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# 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)
|
## 修复腾讯行情接口 URL 拼接逻辑,剔除导致数据残缺的 s_ (简易版) 前缀,确保所有市场强制获取包含时间戳的全量报文 (Task 67)
|
||||||
- 在 `app/api/cron/fetch-prices/route.ts` 的 `fetchStockPrice()` 函数与 `src/actions/market.ts` 的 `getTencentSymbol()` 函数中,将美股资产的前缀映射从 `'s_us'` 强制重构为 `'us'`。
|
- 在 `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`。
|
- **根因分析:** 腾讯财经 gtimg 接口使用 `s_us` 前缀时返回的是"简易版"报文(仅 ~10 个字段),缺失 Index 30 的日期时间字段;使用 `us` 前缀时返回"全量版"报文(60+ 个字段),包含完整的交易时间戳 `2026-05-01 16:00:06`。
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import { AddTransactionDialog } from '@/components/transactions/add-transaction-
|
|||||||
import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
|
import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
|
||||||
import { deleteTransaction } from '@/actions/transaction';
|
import { deleteTransaction } from '@/actions/transaction';
|
||||||
import { importHistoricalPrices } from '@/actions/market';
|
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';
|
import Big from 'big.js';
|
||||||
|
|
||||||
const txTypeMap: Record<string, string> = {
|
const txTypeMap: Record<string, string> = {
|
||||||
@ -55,6 +55,36 @@ function formatNative(value: string, baseCurrency: string): string {
|
|||||||
return `${symbol}${formatted}`;
|
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 } {
|
function formatPnl(value: string, percent: string, baseCurrency: string): { text: string; className: string } {
|
||||||
const isPositive = new Big(value).gte(0);
|
const isPositive = new Big(value).gte(0);
|
||||||
const symbol = getCurrencySymbol(baseCurrency);
|
const symbol = getCurrencySymbol(baseCurrency);
|
||||||
@ -91,11 +121,21 @@ export default function DashboardPage() {
|
|||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [importAssetId, setImportAssetId] = useState<string>('');
|
const [importAssetId, setImportAssetId] = useState<string>('');
|
||||||
const [importText, setImportText] = useState('');
|
const [importText, setImportText] = useState('');
|
||||||
|
const [showCleared, setShowCleared] = useState(false);
|
||||||
|
const [positionsRaw, setPositionsRaw] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filtered = showCleared
|
||||||
|
? positionsRaw
|
||||||
|
: positionsRaw.filter(pos => new Big(pos.quantity || '0').gt('1e-8'));
|
||||||
|
setPositions(filtered);
|
||||||
|
}, [showCleared, positionsRaw]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const summary = await getPortfolioSummary();
|
const summary = await getPortfolioSummary(true);
|
||||||
const allAssets = await getAssets();
|
const allAssets = await getAssets();
|
||||||
|
setPositionsRaw(summary.positions);
|
||||||
setPositions(summary.positions);
|
setPositions(summary.positions);
|
||||||
setTotalCnyValue(summary.totalCnyValue);
|
setTotalCnyValue(summary.totalCnyValue);
|
||||||
setTotalPnlCny(summary.totalPnlCny);
|
setTotalPnlCny(summary.totalPnlCny);
|
||||||
@ -271,8 +311,29 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle>持仓明细</CardTitle>
|
<CardTitle>持仓明细</CardTitle>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showCleared}
|
||||||
|
onChange={(e) => setShowCleared(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-border text-primary focus:ring-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
显示历史持仓
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => exportToCSV(positions)}
|
||||||
|
disabled={positions.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{positions.length === 0 ? (
|
{positions.length === 0 ? (
|
||||||
|
|||||||
@ -173,7 +173,7 @@ function getTodayInShanghai(): Date {
|
|||||||
return new Date(utcDate.getTime() + shanghaiOffset);
|
return new Date(utcDate.getTime() + shanghaiOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortfolioPositions(): Promise<Position[]> {
|
export async function getPortfolioPositions(includeCleared: boolean = false): Promise<Position[]> {
|
||||||
const allTransactions = await db
|
const allTransactions = await db
|
||||||
.select({
|
.select({
|
||||||
id: transactions.id,
|
id: transactions.id,
|
||||||
@ -334,11 +334,17 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
const today = getTodayInShanghai();
|
const today = getTodayInShanghai();
|
||||||
const result: Position[] = [];
|
const result: Position[] = [];
|
||||||
|
|
||||||
|
const CLEARED_QUANTITY_TOLERANCE = new Big('1e-8');
|
||||||
|
|
||||||
for (const [_, holding] of holdings) {
|
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(
|
const cnyValue = calculateCnyValueFromPrice(
|
||||||
holding.quantity,
|
hasPosition ? holding.quantity : new Big('0'),
|
||||||
holding.latestPrice,
|
holding.latestPrice,
|
||||||
holding.baseCurrency,
|
holding.baseCurrency,
|
||||||
rateMap
|
rateMap
|
||||||
@ -442,8 +448,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortfolioSummary() {
|
export async function getPortfolioSummary(includeCleared: boolean = false) {
|
||||||
const positions = await getPortfolioPositions();
|
const positions = await getPortfolioPositions(includeCleared);
|
||||||
|
|
||||||
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
||||||
let totalCnyValue = new Big('0');
|
let totalCnyValue = new Big('0');
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function getTodayInShanghai(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function recordDailySnapshot() {
|
export async function recordDailySnapshot() {
|
||||||
const positions = await getPortfolioPositions();
|
const positions = await getPortfolioPositions(false);
|
||||||
|
|
||||||
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
||||||
const totalValueCny = positions.reduce(
|
const totalValueCny = positions.reduce(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user