feat(portfolio): 支持显示已清仓历史持仓,增加防乱码 CSV 导出功能

This commit is contained in:
2026-05-02 15:04:56 +08:00
parent 2570144112
commit 3e81c1dc5b
4 changed files with 84 additions and 9 deletions
+64 -3
View File
@@ -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<string, string> = {
@@ -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<string>('');
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(() => {
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() {
</Card>
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<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>
<CardContent>
{positions.length === 0 ? (