diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 4e3a625..3d1ca49 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -1,6 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { getPortfolioPositions } from '@/actions/portfolio';
-import { formatQuantity } from '@/lib/formatters';
+import { getPortfolioSummary } from '@/actions/portfolio';
+import { formatQuantity, formatAmount } from '@/lib/formatters';
import AllocationChart from '@/components/dashboard/allocation-chart';
const CHART_COLORS = [
@@ -13,12 +13,13 @@ const CHART_COLORS = [
];
export default async function DashboardPage() {
- const positions = await getPortfolioPositions();
+ const { positions, totalCnyValue, chartData } = await getPortfolioSummary();
- const chartData = positions.map((pos, index) => ({
- name: pos.symbol,
- value: Number(pos.quantity),
- fill: CHART_COLORS[index % CHART_COLORS.length],
+ const formattedTotal = formatAmount(totalCnyValue);
+
+ const displayChartData = chartData.map((item) => ({
+ ...item,
+ value: Number(item.value),
}));
return (
@@ -28,6 +29,22 @@ export default async function DashboardPage() {
您的跨界记账中枢。
+
+
+
+
+ ¥
+
+
+ {formattedTotal}
+
+
+ 总资产 (CNY)
+
+
+
+
+
{positions.length === 0 ? (
@@ -58,6 +75,12 @@ export default async function DashboardPage() {
结算币种
{pos.baseCurrency}
+
+ CNY 估值
+
+ ¥{formatAmount(pos.cnyValue)}
+
+
@@ -70,7 +93,7 @@ export default async function DashboardPage() {
资产分布
-
+
diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts
index 9f1e622..3aa936c 100644
--- a/src/actions/portfolio.ts
+++ b/src/actions/portfolio.ts
@@ -1,11 +1,80 @@
'use server';
import { db } from '@/db';
-import { transactions, assets } from '@/db/schema';
+import { transactions, assets, exchangeRates } from '@/db/schema';
import Big from 'big.js';
import { desc, eq } from 'drizzle-orm';
-export async function getPortfolioPositions() {
+interface Position {
+ assetId: string;
+ symbol: string;
+ type: string;
+ quantity: string;
+ baseCurrency: string;
+ cnyValue: string;
+}
+
+interface RawRate {
+ fromCurrency: string;
+ toCurrency: string;
+ rate: string;
+}
+
+function buildRateMap(rates: RawRate[]): Map {
+ const map = new Map();
+ for (const r of rates) {
+ map.set(`${r.fromCurrency}_${r.toCurrency}`, r.rate);
+ }
+ return map;
+}
+
+function getRate(
+ rateMap: Map,
+ from: string,
+ to: string
+): string | null {
+ const direct = rateMap.get(`${from}_${to}`);
+ if (direct) return direct;
+ return null;
+}
+
+function calculateCnyValue(
+ quantity: Big,
+ baseCurrency: string,
+ rateMap: Map,
+ cryptoPrices: Map
+): Big {
+ if (baseCurrency === 'CNY') {
+ return quantity;
+ }
+
+ const directRate = getRate(rateMap, baseCurrency, 'CNY');
+ if (directRate) {
+ return quantity.times(directRate);
+ }
+
+ const usdToCny = getRate(rateMap, 'USD', 'CNY');
+ if (!usdToCny) {
+ return new Big('0');
+ }
+
+ const priceKey = `${baseCurrency}_USD`;
+ const cryptoPrice = cryptoPrices.get(priceKey);
+ if (cryptoPrice) {
+ const usdValue = quantity.times(cryptoPrice);
+ return usdValue.times(usdToCny);
+ }
+
+ const usdRate = getRate(rateMap, baseCurrency, 'USD');
+ if (usdRate) {
+ const usdValue = quantity.times(usdRate);
+ return usdValue.times(usdToCny);
+ }
+
+ return new Big('0');
+}
+
+export async function getPortfolioPositions(): Promise {
const allTransactions = await db
.select({
txType: transactions.txType,
@@ -14,6 +83,7 @@ export async function getPortfolioPositions() {
assetSymbol: assets.symbol,
assetType: assets.type,
assetBaseCurrency: assets.baseCurrency,
+ assetPrice: transactions.price,
})
.from(transactions)
.leftJoin(assets, eq(assets.id, transactions.assetId))
@@ -25,6 +95,7 @@ export async function getPortfolioPositions() {
type: string;
quantity: Big;
baseCurrency: string;
+ latestPrice: string;
}>();
for (const tx of allTransactions) {
@@ -38,6 +109,7 @@ export async function getPortfolioPositions() {
type: tx.assetType || 'CASH',
quantity: new Big('0'),
baseCurrency: tx.assetBaseCurrency || '',
+ latestPrice: tx.assetPrice || '0',
});
}
@@ -50,19 +122,107 @@ export async function getPortfolioPositions() {
} else if (tx.txType === 'DIVIDEND') {
holding.quantity = holding.quantity.plus(tx.quantity);
}
+
+ if (tx.assetPrice) {
+ holding.latestPrice = tx.assetPrice;
+ }
}
- const result = [];
+ const rates = await db.select({
+ fromCurrency: exchangeRates.fromCurrency,
+ toCurrency: exchangeRates.toCurrency,
+ rate: exchangeRates.rate,
+ }).from(exchangeRates);
+
+ const rateMap = buildRateMap(rates);
+
+ const cryptoSymbols = new Set(['BTC', 'ETH', 'SOL', 'BNB', 'XRP', 'ADA', 'DOGE', 'AVAX', 'MATIC', 'DOT']);
+ const cryptoPrices = new Map();
+ for (const [_, holding] of holdings) {
+ if (holding.type === 'CRYPTO' && cryptoSymbols.has(holding.symbol.toUpperCase())) {
+ const priceKey = `${holding.symbol}_USD`;
+ const usdRate = getRate(rateMap, holding.symbol, 'USD');
+ if (usdRate) {
+ cryptoPrices.set(priceKey, usdRate);
+ }
+ }
+ }
+
+ const result: Position[] = [];
+ let totalCnyValue = new Big('0');
+
for (const [_, holding] of holdings) {
if (holding.quantity.lte(0)) continue;
+
+ let cnyValue: Big;
+
+ if (holding.type === 'CRYPTO') {
+ const symbol = holding.symbol.toUpperCase();
+ const btcToUsd = getRate(rateMap, symbol, 'USD');
+ const usdToCny = getRate(rateMap, 'USD', 'CNY');
+
+ if (btcToUsd && usdToCny) {
+ const usdValue = holding.quantity.times(holding.latestPrice || '1');
+ cnyValue = usdValue.times(usdToCny);
+ } else {
+ cnyValue = new Big('0');
+ }
+ } else if (holding.baseCurrency === 'CNY') {
+ cnyValue = holding.quantity.times(holding.latestPrice || '1');
+ } else {
+ const directRate = getRate(rateMap, holding.baseCurrency, 'CNY');
+ if (directRate) {
+ cnyValue = holding.quantity.times(holding.latestPrice || '1').times(directRate);
+ } else {
+ const usdRate = getRate(rateMap, holding.baseCurrency, 'USD');
+ const usdToCny = getRate(rateMap, 'USD', 'CNY');
+ if (usdRate && usdToCny) {
+ cnyValue = holding.quantity.times(holding.latestPrice || '1').times(usdRate).times(usdToCny);
+ } else {
+ cnyValue = new Big('0');
+ }
+ }
+ }
+
+ totalCnyValue = totalCnyValue.plus(cnyValue);
+
result.push({
assetId: holding.assetId,
symbol: holding.symbol,
type: holding.type,
quantity: holding.quantity.toString(),
baseCurrency: holding.baseCurrency,
+ cnyValue: cnyValue.toString(),
});
}
return result;
}
+
+export async function getPortfolioSummary() {
+ const positions = await getPortfolioPositions();
+
+ const totalCnyValue = positions.reduce(
+ (sum, pos) => sum.plus(new Big(pos.cnyValue)),
+ new Big('0')
+ );
+
+ const chartData = positions.map((pos, index) => ({
+ name: pos.symbol,
+ value: new Big(pos.cnyValue),
+ fill: [
+ '#3b82f6',
+ '#8b5cf6',
+ '#10b981',
+ '#f59e0b',
+ '#ef4444',
+ '#06b6d4',
+ ][index % 6],
+ }));
+
+ return {
+ positions,
+ totalCnyValue: totalCnyValue.toString(),
+ chartData,
+ };
+}
diff --git a/src/components/dashboard/allocation-chart.tsx b/src/components/dashboard/allocation-chart.tsx
index 1e05e0f..292f318 100644
--- a/src/components/dashboard/allocation-chart.tsx
+++ b/src/components/dashboard/allocation-chart.tsx
@@ -52,10 +52,12 @@ export default function AllocationChart({ data }: AllocationChartProps) {
color: 'hsl(var(--foreground))',
fontSize: '14px',
}}
- formatter={(value: number, name: string) => [
- value.toLocaleString(),
- name,
- ]}
+ formatter={(value) => {
+ const num = Number(value);
+ return [
+ `¥${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
+ ];
+ }}
/>