feat(ui): 首页实装历史净值面积走势图,并挂载自动化快照触发逻辑
This commit is contained in:
parent
4c4e6ab565
commit
e70c0602c8
@ -30,6 +30,7 @@
|
|||||||
- 打通 `/dashboard/assets` 与 `/dashboard/transactions` 页面前后端数据流转,修复早期录入与 404 缺陷。
|
- 打通 `/dashboard/assets` 与 `/dashboard/transactions` 页面前后端数据流转,修复早期录入与 404 缺陷。
|
||||||
- 完成 UI 层高精度数据格式化,针对不同资产类型实现动态精度展示,清理因数据库 `numeric` 导致的尾随零问题。
|
- 完成 UI 层高精度数据格式化,针对不同资产类型实现动态精度展示,清理因数据库 `numeric` 导致的尾随零问题。
|
||||||
- 引入 `recharts` 图表引擎,构建了基于实时 CNY 估值的资产分布环形图。
|
- 引入 `recharts` 图表引擎,构建了基于实时 CNY 估值的资产分布环形图。
|
||||||
|
- 实装基于 Recharts 的历史净值面积图 (NetWorthChart),支持总市值与投入本金的双轨趋势对比。
|
||||||
- 优化表单交互:实装了交易所与币种的智能联动逻辑,并运用 `disabled` 属性实现了表单字段的只读防腐锁定。
|
- 优化表单交互:实装了交易所与币种的智能联动逻辑,并运用 `disabled` 属性实现了表单字段的只读防腐锁定。
|
||||||
|
|
||||||
## UX 与全局交互 (UI/UX)
|
## UX 与全局交互 (UI/UX)
|
||||||
|
|||||||
@ -22,8 +22,10 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getPortfolioSummary } from '@/actions/portfolio';
|
import { getPortfolioSummary } from '@/actions/portfolio';
|
||||||
import { getAssets } from '@/actions/asset';
|
import { getAssets } from '@/actions/asset';
|
||||||
|
import { recordDailySnapshot, getSnapshots } from '@/actions/snapshots';
|
||||||
import { formatQuantity, formatAmount } from '@/lib/formatters';
|
import { formatQuantity, formatAmount } from '@/lib/formatters';
|
||||||
import AllocationChart from '@/components/dashboard/allocation-chart';
|
import AllocationChart from '@/components/dashboard/allocation-chart';
|
||||||
|
import NetWorthChart from '@/components/dashboard/net-worth-chart';
|
||||||
import { SyncButton } from '@/components/assets/sync-button';
|
import { SyncButton } from '@/components/assets/sync-button';
|
||||||
import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog';
|
import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog';
|
||||||
import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
|
import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
|
||||||
@ -84,6 +86,7 @@ export default function DashboardPage() {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [updateTarget, setUpdateTarget] = useState<any>(null);
|
const [updateTarget, setUpdateTarget] = useState<any>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<any>(null);
|
const [deleteTarget, setDeleteTarget] = useState<any>(null);
|
||||||
|
const [snapshots, setSnapshots] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@ -99,6 +102,15 @@ export default function DashboardPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadSnapshots() {
|
||||||
|
await recordDailySnapshot();
|
||||||
|
const data = await getSnapshots({ limit: 30 });
|
||||||
|
setSnapshots(data);
|
||||||
|
}
|
||||||
|
loadSnapshots();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
const toggleExpand = (id: string) => {
|
||||||
setExpandedIds(prev => ({ ...prev, [id]: !prev[id] }));
|
setExpandedIds(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
};
|
};
|
||||||
@ -170,6 +182,15 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium">净值走势</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<NetWorthChart snapshots={snapshots} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>持仓明细</CardTitle>
|
<CardTitle>持仓明细</CardTitle>
|
||||||
|
|||||||
141
src/components/dashboard/net-worth-chart.tsx
Normal file
141
src/components/dashboard/net-worth-chart.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
interface Snapshot {
|
||||||
|
date: string;
|
||||||
|
totalValueCny: string;
|
||||||
|
totalCostCny: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetWorthChartProps {
|
||||||
|
snapshots: Snapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: string; payload: Snapshot }>; 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 isPositive = pnl >= 0;
|
||||||
|
|
||||||
|
const formattedDate = (label || '').replace(/-/g, '/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
borderColor: 'hsl(var(--border))',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '600', marginBottom: '8px', fontSize: '14px' }}>
|
||||||
|
{formattedDate}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '24px', marginBottom: '4px' }}>
|
||||||
|
<span style={{ color: 'hsl(var(--muted-foreground))', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '2px', backgroundColor: '#f59e0b' }} />
|
||||||
|
总市值
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: '700', color: '#f59e0b' }}>
|
||||||
|
¥{totalValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '24px', marginBottom: '4px' }}>
|
||||||
|
<span style={{ color: 'hsl(var(--muted-foreground))', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '2px', backgroundColor: '#9ca3af' }} />
|
||||||
|
投入本金
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: '600', color: 'hsl(var(--muted-foreground))' }}>
|
||||||
|
¥{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))' }}>
|
||||||
|
<span style={{ color: 'hsl(var(--muted-foreground))', fontWeight: '500' }}>净盈亏</span>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: '700',
|
||||||
|
color: isPositive ? '#ef4444' : '#22c55e',
|
||||||
|
}}>
|
||||||
|
{isPositive ? '+' : ''}¥{pnl.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
{' '}({isPositive ? '+' : ''}{pnlPercent.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NetWorthChart({ snapshots }: NetWorthChartProps) {
|
||||||
|
if (!snapshots || snapshots.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[320px] text-muted-foreground">
|
||||||
|
暂无历史数据
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = snapshots.map(s => ({
|
||||||
|
date: s.date.replace(/-/g, '/'),
|
||||||
|
totalValueCny: parseFloat(s.totalValueCny),
|
||||||
|
totalCostCny: parseFloat(s.totalCostCny),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[320px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 10 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="totalValueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="totalCostGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#9ca3af" stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor="#9ca3af" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
axisLine={{ stroke: 'hsl(var(--border))' }}
|
||||||
|
tickLine={{ stroke: 'hsl(var(--border))' }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
axisLine={{ stroke: 'hsl(var(--border))' }}
|
||||||
|
tickLine={{ stroke: 'hsl(var(--border))' }}
|
||||||
|
tickFormatter={(value: number) => {
|
||||||
|
if (value >= 1000000) return `¥${(value / 1000000).toFixed(1)}M`;
|
||||||
|
if (value >= 1000) return `¥${(value / 1000).toFixed(0)}K`;
|
||||||
|
return `¥${value.toFixed(0)}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="totalValueCny"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#totalValueGradient)"
|
||||||
|
name="总市值"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="totalCostCny"
|
||||||
|
stroke="#9ca3af"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
fill="url(#totalCostGradient)"
|
||||||
|
name="投入本金"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user