From b9186d46995d42c4ecb22c12a7aefb8b37154722 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Tue, 28 Apr 2026 00:57:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(ledger):=20=E5=BC=95=E5=85=A5=20latestPric?= =?UTF-8?q?e=20=E5=AD=97=E6=AE=B5=E4=B8=8E=E5=8E=86=E5=8F=B2=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E8=BF=BD=E8=B8=AA=EF=BC=8C=E5=AE=9E=E8=A3=85=20P&L=20?= =?UTF-8?q?=E7=9B=88=E4=BA=8F=E8=AE=A1=E7=AE=97=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dashboard/assets/page.tsx | 27 +++-- app/dashboard/page.tsx | 89 +++++++++------ src/actions/asset.ts | 22 ++++ src/actions/portfolio.ts | 102 ++++++++---------- src/components/assets/update-price-dialog.tsx | 101 +++++++++++++++++ src/db/schema.ts | 1 + 6 files changed, 241 insertions(+), 101 deletions(-) create mode 100644 src/components/assets/update-price-dialog.tsx diff --git a/app/dashboard/assets/page.tsx b/app/dashboard/assets/page.tsx index f1a457e..27a3c06 100644 --- a/app/dashboard/assets/page.tsx +++ b/app/dashboard/assets/page.tsx @@ -1,5 +1,6 @@ import { getAssets } from '@/actions/asset'; import { AddAssetDialog } from '@/components/assets/add-asset-dialog'; +import { UpdatePriceDialog } from '@/components/assets/update-price-dialog'; import { Table, TableBody, @@ -15,33 +16,35 @@ export default async function AssetsPage() { const typeLabels: Record = { STOCK: '股票', - CRYPTO: '加密货币', - CASH: '现金', + CRYPTO: '加密貨幣', + CASH: '現金', }; return (
-

资产列表

+

資產列表

- 数据库中所有已录入的资产 + 數據庫中所有已錄入的資產 - 资产代码 - 类型 - 基础币种 - 创建时间 + 資產代碼 + 類型 + 基礎幣種 + 當前市價 (Latest Price) + 創建時間 + 操作 {assets.length === 0 ? ( - - 暂无资产,点击"添加资产"按钮录入第一个资产 + + 暫無資產,點擊"添加資產"按鈕錄入第一個資產 ) : ( @@ -50,11 +53,15 @@ export default async function AssetsPage() { {asset.symbol} {typeLabels[asset.type] || asset.type} {asset.baseCurrency} + {asset.latestPrice} {asset.createdAt ? new Date(asset.createdAt).toLocaleString('zh-CN') : '-'} + + + )) )} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 3d1ca49..d97dd54 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { getPortfolioSummary } from '@/actions/portfolio'; import { formatQuantity, formatAmount } from '@/lib/formatters'; import AllocationChart from '@/components/dashboard/allocation-chart'; +import Big from 'big.js'; const CHART_COLORS = [ '#3b82f6', @@ -13,9 +14,11 @@ const CHART_COLORS = [ ]; export default async function DashboardPage() { - const { positions, totalCnyValue, chartData } = await getPortfolioSummary(); + const { positions, totalCnyValue, chartData, totalPnlCny } = await getPortfolioSummary(); const formattedTotal = formatAmount(totalCnyValue); + const formattedPnl = formatAmount(totalPnlCny); + const pnlIsPositive = new Big(totalPnlCny).gte(0); const displayChartData = chartData.map((item) => ({ ...item, @@ -42,6 +45,12 @@ export default async function DashboardPage() { 总资产 (CNY) +
+ 总盈亏: + + {pnlIsPositive ? '+' : ''}{formattedPnl} + +
@@ -53,38 +62,56 @@ export default async function DashboardPage() { ) : ( - positions.map((pos) => ( - - - - {pos.symbol} - - {pos.type} - - - - -
-
- 持仓数量 - - {formatQuantity(pos.quantity, pos.type)} + positions.map((pos) => { + const posPnl = new Big(pos.pnlCny); + const posPnlPositive = posPnl.gte(0); + const formattedPosPnl = formatAmount(pos.pnlCny); + + return ( + + + + {pos.symbol} + + {pos.type} + + + +
+
+ 持仓数量 + + {formatQuantity(pos.quantity, pos.type)} + +
+
+ 结算币种 + {pos.baseCurrency} +
+
+ CNY 估值 + + ¥{formatAmount(pos.cnyValue)} + +
+
+ 成本 (CNY) + + ¥{formatAmount(pos.totalCostCny)} + +
+
+ 盈亏 + + {posPnlPositive ? '+' : ''}{formattedPosPnl} + +
-
- 结算币种 - {pos.baseCurrency} -
-
- CNY 估值 - - ¥{formatAmount(pos.cnyValue)} - -
-
- - - )) + + + ); + }) )}
diff --git a/src/actions/asset.ts b/src/actions/asset.ts index 183a314..85a4ad1 100644 --- a/src/actions/asset.ts +++ b/src/actions/asset.ts @@ -2,6 +2,8 @@ import { db } from '@/db'; import { assets, assetTypeEnum } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; import { z } from 'zod'; const createAssetSchema = z.object({ @@ -34,4 +36,24 @@ export async function createAsset(params: z.infer) { export async function getAssets() { return db.select().from(assets); +} + +const updatePriceSchema = z.object({ + assetId: z.string().min(1, 'Asset ID is required'), + newPrice: z.string().min(1, 'Price is required'), +}); + +export async function updateAssetPrice(params: z.infer) { + const validation = updatePriceSchema.safeParse(params); + if (!validation.success) { + return { success: false, error: validation.error.issues[0].message }; + } + + try { + await db.update(assets).set({ latestPrice: params.newPrice }).where(eq(assets.id, params.assetId)); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error: unknown) { + throw error; + } } \ No newline at end of file diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 3aa936c..c17ec11 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -12,6 +12,8 @@ interface Position { quantity: string; baseCurrency: string; cnyValue: string; + totalCostCny: string; + pnlCny: string; } interface RawRate { @@ -38,19 +40,21 @@ function getRate( return null; } -function calculateCnyValue( +function calculateCnyValueFromPrice( quantity: Big, + latestPrice: string, baseCurrency: string, - rateMap: Map, - cryptoPrices: Map + rateMap: Map ): Big { + const price = new Big(latestPrice || '0'); + if (baseCurrency === 'CNY') { - return quantity; + return quantity.times(price); } const directRate = getRate(rateMap, baseCurrency, 'CNY'); if (directRate) { - return quantity.times(directRate); + return quantity.times(price).times(directRate); } const usdToCny = getRate(rateMap, 'USD', 'CNY'); @@ -58,17 +62,9 @@ function calculateCnyValue( 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 quantity.times(price).times(usdRate).times(usdToCny); } return new Big('0'); @@ -79,11 +75,13 @@ export async function getPortfolioPositions(): Promise { .select({ txType: transactions.txType, quantity: transactions.quantity, + price: transactions.price, + exchangeRate: transactions.exchangeRate, assetId: transactions.assetId, assetSymbol: assets.symbol, assetType: assets.type, assetBaseCurrency: assets.baseCurrency, - assetPrice: transactions.price, + assetLatestPrice: assets.latestPrice, }) .from(transactions) .leftJoin(assets, eq(assets.id, transactions.assetId)) @@ -96,6 +94,7 @@ export async function getPortfolioPositions(): Promise { quantity: Big; baseCurrency: string; latestPrice: string; + totalCostCny: Big; }>(); for (const tx of allTransactions) { @@ -109,22 +108,28 @@ export async function getPortfolioPositions(): Promise { type: tx.assetType || 'CASH', quantity: new Big('0'), baseCurrency: tx.assetBaseCurrency || '', - latestPrice: tx.assetPrice || '0', + latestPrice: tx.assetLatestPrice || '0', + totalCostCny: new Big('0'), }); } const holding = holdings.get(tx.assetId)!; - if (tx.txType === 'BUY' || tx.txType === 'AIRDROP') { + if (tx.txType === 'BUY') { holding.quantity = holding.quantity.plus(tx.quantity); + const costPerUnit = tx.quantity.times(tx.price); + const costCny = costPerUnit.times(tx.exchangeRate || '1'); + holding.totalCostCny = holding.totalCostCny.plus(costCny); } else if (tx.txType === 'SELL') { holding.quantity = holding.quantity.minus(tx.quantity); + } else if (tx.txType === 'AIRDROP') { + holding.quantity = holding.quantity.plus(tx.quantity); } else if (tx.txType === 'DIVIDEND') { holding.quantity = holding.quantity.plus(tx.quantity); } - if (tx.assetPrice) { - holding.latestPrice = tx.assetPrice; + if (tx.assetLatestPrice) { + holding.latestPrice = tx.assetLatestPrice; } } @@ -136,56 +141,25 @@ export async function getPortfolioPositions(): Promise { 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'); + let totalPnlCny = 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'); - } - } - } + const cnyValue = calculateCnyValueFromPrice( + holding.quantity, + holding.latestPrice, + holding.baseCurrency, + rateMap + ); totalCnyValue = totalCnyValue.plus(cnyValue); + const pnlCny = cnyValue.minus(holding.totalCostCny); + totalPnlCny = totalPnlCny.plus(pnlCny); + result.push({ assetId: holding.assetId, symbol: holding.symbol, @@ -193,6 +167,8 @@ export async function getPortfolioPositions(): Promise { quantity: holding.quantity.toString(), baseCurrency: holding.baseCurrency, cnyValue: cnyValue.toString(), + totalCostCny: holding.totalCostCny.toString(), + pnlCny: pnlCny.toString(), }); } @@ -207,6 +183,11 @@ export async function getPortfolioSummary() { new Big('0') ); + const totalPnlCny = positions.reduce( + (sum, pos) => sum.plus(new Big(pos.pnlCny)), + new Big('0') + ); + const chartData = positions.map((pos, index) => ({ name: pos.symbol, value: new Big(pos.cnyValue), @@ -223,6 +204,7 @@ export async function getPortfolioSummary() { return { positions, totalCnyValue: totalCnyValue.toString(), + totalPnlCny: totalPnlCny.toString(), chartData, }; } diff --git a/src/components/assets/update-price-dialog.tsx b/src/components/assets/update-price-dialog.tsx new file mode 100644 index 0000000..c161fb3 --- /dev/null +++ b/src/components/assets/update-price-dialog.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { useRouter } from 'next/navigation'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { RefreshCw } from 'lucide-react'; +import { updateAssetPrice } from '@/actions/asset'; + +const updatePriceSchema = z.object({ + newPrice: z.string().min(1, '價格不能為空'), +}); + +type UpdatePriceForm = z.infer; + +interface UpdatePriceDialogProps { + assetId: string; + currentPrice: string; +} + +export function UpdatePriceDialog({ assetId, currentPrice }: UpdatePriceDialogProps) { + const [open, setOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(updatePriceSchema), + defaultValues: { + newPrice: currentPrice, + }, + }); + + function onSubmit(values: UpdatePriceForm) { + startTransition(async () => { + await updateAssetPrice({ assetId, newPrice: values.newPrice }); + setOpen(false); + router.refresh(); + }); + } + + return ( + + + + + + + 更新現價 + + 輸入該資產的最新市場單價 + + +
+ + ( + + 最新價格 + + + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index a7e4245..3f66c0b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -20,6 +20,7 @@ export const assets = pgTable("assets", { symbol: varchar("symbol", { length: 20 }).notNull().unique(), type: assetTypeEnum("type").notNull(), baseCurrency: varchar("base_currency", { length: 10 }).notNull(), + latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }) .defaultNow(), });