v1.0.2 (2026-04-12)

- 🗑️ 删除交易记录功能:支持删除误添加的交易记录,自动回滚账户余额和持仓变化
- 💰 显示货币选择器:支持 CNY/USD/HKD 三种货币显示,默认 CNY
- 🔄 实时货币转换:根据汇率自动转换总资产显示
This commit is contained in:
kennethcheng 2026-04-12 06:27:36 +08:00
parent 6ceb4a8e9b
commit 1df2950097
4 changed files with 215 additions and 7 deletions

View File

@ -181,6 +181,9 @@ npx prisma db seed
# 5. 启动开发服务器 # 5. 启动开发服务器
npm run dev npm run dev
# 6. 重置数据库
npx prisma migrate reset --force
``` ```
### 环境变量 ### 环境变量
@ -382,6 +385,12 @@ MIT License - 详见 [LICENSE](LICENSE) 文件
## 更新日志 ## 更新日志
### v1.0.2 (2026-04-12)
- 🗑️ 删除交易记录功能:支持删除误添加的交易记录,自动回滚账户余额和持仓变化
- 💰 显示货币选择器:支持 CNY/USD/HKD 三种货币显示,默认 CNY
- 🔄 实时货币转换:根据汇率自动转换总资产显示
### v1.0.1 (2026-04-12) ### v1.0.1 (2026-04-12)
- 🔧 账户名称优化改为港股账户、美股账户、A股账户、加密货币账户 - 🔧 账户名称优化改为港股账户、美股账户、A股账户、加密货币账户

View File

@ -134,3 +134,87 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Failed to create transaction' }, { status: 500 }) return NextResponse.json({ error: 'Failed to create transaction' }, { status: 500 })
} }
} }
// 删除交易记录
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'Transaction ID is required' }, { status: 400 })
}
// 获取交易记录
const transaction = await prisma.transaction.findUnique({
where: { id },
})
if (!transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// 开启事务处理回滚
await prisma.$transaction(async (tx) => {
// 如果是入金/出金,反向更新账户余额
if (transaction.type === 'DEPOSIT' || transaction.type === 'WITHDRAW') {
const balanceChange = transaction.type === 'DEPOSIT'
? new Prisma.Decimal(transaction.amount).negated()
: new Prisma.Decimal(transaction.amount)
await tx.account.update({
where: { id: transaction.accountId },
data: { balance: { increment: balanceChange } },
})
}
// 如果是买入/卖出,反向更新持仓
if ((transaction.type === 'BUY' || transaction.type === 'SELL') && transaction.symbol && transaction.quantity && transaction.price) {
const position = await tx.position.findUnique({
where: { accountId_symbol: { accountId: transaction.accountId, symbol: transaction.symbol } },
})
if (position) {
if (transaction.type === 'BUY') {
// 撤销买入:减少持仓数量
const newQty = position.quantity.minus(new Prisma.Decimal(transaction.quantity))
if (newQty.lte(0)) {
// 持仓全部撤销,删除记录
await tx.position.delete({
where: { accountId_symbol: { accountId: transaction.accountId, symbol: transaction.symbol } },
})
} else {
// 按比例撤销平均成本
const undoCost = new Prisma.Decimal(transaction.quantity).times(new Prisma.Decimal(transaction.price))
const remainingCost = position.averageCost.times(position.quantity).minus(undoCost)
const newAvgCost = remainingCost.div(newQty)
await tx.position.update({
where: { accountId_symbol: { accountId: transaction.accountId, symbol: transaction.symbol } },
data: {
quantity: newQty,
averageCost: newAvgCost,
},
})
}
} else if (transaction.type === 'SELL') {
// 撤销卖出:恢复持仓数量
const newQty = position.quantity.plus(new Prisma.Decimal(transaction.quantity))
await tx.position.update({
where: { accountId_symbol: { accountId: transaction.accountId, symbol: transaction.symbol } },
data: { quantity: newQty },
})
}
}
}
// 删除交易记录
await tx.transaction.delete({
where: { id },
})
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete transaction error:', error)
return NextResponse.json({ error: 'Failed to delete transaction' }, { status: 500 })
}
}

View File

@ -21,7 +21,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { import {
fetchAccounts, fetchTransactions, fetchPositions, fetchAccounts, fetchTransactions, fetchPositions,
fetchSecurities, createTransaction, formatCurrency, formatPercent, fetchSecurities, createTransaction, deleteTransaction, formatCurrency, formatPercent,
marketLabels, transactionTypeLabels marketLabels, transactionTypeLabels
} from '@/lib/api' } from '@/lib/api'
import { Account, Position, Transaction, MarketType, TransactionType, Security } from '@/types' import { Account, Position, Transaction, MarketType, TransactionType, Security } from '@/types'
@ -77,6 +77,20 @@ interface AnalyticsSummary {
positionCount: number positionCount: number
} }
// 货币转换函数(以 USD 为基准)
const EXCHANGE_RATES: Record<string, number> = {
USD: 1,
CNY: 7.24, // 1 USD = 7.24 CNY
HKD: 7.78, // 1 USD = 7.78 HKD
}
function convertCurrency(amount: number, from: string, to: string): number {
if (from === to) return amount
// 先转换为 USD再转换为目标货币
const usdAmount = amount / EXCHANGE_RATES[from]
return usdAmount * EXCHANGE_RATES[to]
}
// 主页面组件 // 主页面组件
export default function Dashboard() { export default function Dashboard() {
// 状态定义 // 状态定义
@ -97,6 +111,13 @@ export default function Dashboard() {
const [importFile, setImportFile] = useState<File | null>(null) const [importFile, setImportFile] = useState<File | null>(null)
const [importData, setImportData] = useState<ImportTransaction[]>([]) const [importData, setImportData] = useState<ImportTransaction[]>([])
// 显示货币状态(默认 CNY
const [displayCurrency, setDisplayCurrency] = useState<'CNY' | 'USD' | 'HKD'>('CNY')
// 交易记录删除状态
const [transactionToDelete, setTransactionToDelete] = useState<Transaction | null>(null)
const [showDeleteTxDialog, setShowDeleteTxDialog] = useState(false)
// 交易表单状态 // 交易表单状态
const [txForm, setTxForm] = useState({ const [txForm, setTxForm] = useState({
type: 'BUY' as TransactionType, type: 'BUY' as TransactionType,
@ -253,6 +274,27 @@ export default function Dashboard() {
setShowDeleteDialog(true) setShowDeleteDialog(true)
} }
// 打开删除交易记录对话框
const openDeleteTxDialog = (tx: Transaction) => {
setTransactionToDelete(tx)
setShowDeleteTxDialog(true)
}
// 删除交易记录
const handleDeleteTransaction = async () => {
if (!transactionToDelete) return
try {
await deleteTransaction(transactionToDelete.id)
toast.success('交易记录已删除')
setShowDeleteTxDialog(false)
setTransactionToDelete(null)
loadData()
} catch (error) {
toast.error('删除交易记录失败')
}
}
// 重置交易表单 // 重置交易表单
const resetTxForm = () => { const resetTxForm = () => {
setTxForm({ setTxForm({
@ -412,6 +454,17 @@ export default function Dashboard() {
<h1 className="text-xl font-bold"></h1> <h1 className="text-xl font-bold"></h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 显示货币选择 */}
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as 'CNY' | 'USD' | 'HKD')}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CNY">CNY ¥</SelectItem>
<SelectItem value="USD">USD $</SelectItem>
<SelectItem value="HKD">HKD $</SelectItem>
</SelectContent>
</Select>
{/* 账户选择下拉框 */} {/* 账户选择下拉框 */}
<Select value={selectedAccountId} onValueChange={(v) => v && setSelectedAccountId(v)}> <Select value={selectedAccountId} onValueChange={(v) => v && setSelectedAccountId(v)}>
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[140px]">
@ -460,13 +513,13 @@ export default function Dashboard() {
{/* 总资产卡片 */} {/* 总资产卡片 */}
<Card className="bg-gradient-to-br from-blue-600 to-blue-700 text-white border-0"> <Card className="bg-gradient-to-br from-blue-600 to-blue-700 text-white border-0">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium opacity-90"> (USD)</CardTitle> <CardTitle className="text-sm font-medium opacity-90"> ({displayCurrency})</CardTitle>
<Wallet className="h-4 w-4 opacity-80" /> <Wallet className="h-4 w-4 opacity-80" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatCurrency(analytics?.summary?.totalMarketValue || 0)}</div> <div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalMarketValue || 0, 'USD', displayCurrency), displayCurrency)}</div>
<p className="text-xs opacity-80 mt-1"> <p className="text-xs opacity-80 mt-1">
{formatCurrency(analytics?.summary?.totalCostBasis || 0)} {formatCurrency(convertCurrency(analytics?.summary?.totalCostBasis || 0, 'USD', displayCurrency), displayCurrency)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -478,7 +531,7 @@ export default function Dashboard() {
<TrendingUp className="h-4 w-4 opacity-80" /> <TrendingUp className="h-4 w-4 opacity-80" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatCurrency(analytics?.summary?.totalPnL || 0)}</div> <div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalPnL || 0, 'USD', displayCurrency), displayCurrency)}</div>
<p className={`text-xs mt-1 flex items-center gap-1 ${(analytics?.summary?.totalPnL || 0) >= 0 ? 'text-emerald-200' : 'text-red-200'}`}> <p className={`text-xs mt-1 flex items-center gap-1 ${(analytics?.summary?.totalPnL || 0) >= 0 ? 'text-emerald-200' : 'text-red-200'}`}>
{(analytics?.summary?.totalPnL || 0) >= 0 ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />} {(analytics?.summary?.totalPnL || 0) >= 0 ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
{formatPercent(analytics?.summary?.totalPnLPercent || 0)} {formatPercent(analytics?.summary?.totalPnLPercent || 0)}
@ -493,7 +546,7 @@ export default function Dashboard() {
<DollarSign className="h-4 w-4 opacity-80" /> <DollarSign className="h-4 w-4 opacity-80" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatCurrency(analytics?.summary?.totalMarketValue || 0)}</div> <div className="text-2xl font-bold">{formatCurrency(convertCurrency(analytics?.summary?.totalMarketValue || 0, 'USD', displayCurrency), displayCurrency)}</div>
<p className="text-xs opacity-80 mt-1">{analytics?.summary?.positionCount || 0} </p> <p className="text-xs opacity-80 mt-1">{analytics?.summary?.positionCount || 0} </p>
</CardContent> </CardContent>
</Card> </Card>
@ -680,6 +733,7 @@ export default function Dashboard() {
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -715,10 +769,15 @@ export default function Dashboard() {
<span className="text-sm">{tx.account?.name}</span> <span className="text-sm">{tx.account?.name}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={() => openDeleteTxDialog(tx)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow> </TableRow>
)) : ( )) : (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -1049,6 +1108,54 @@ export default function Dashboard() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 删除交易记录确认对话框 */}
<Dialog open={showDeleteTxDialog} onOpenChange={setShowDeleteTxDialog}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{transactionToDelete && (
<div className="py-4 space-y-3">
<div className="bg-muted/50 p-3 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{transactionTypeLabels[transactionToDelete.type]}</span>
</div>
{transactionToDelete.symbol && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{transactionToDelete.symbol}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">{formatCurrency(parseFloat(transactionToDelete.amount), transactionToDelete.currency)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="text-sm">{new Date(transactionToDelete.executedAt).toLocaleString('zh-CN')}</span>
</div>
</div>
<div className="text-sm text-destructive text-center">
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => {
setShowDeleteTxDialog(false)
setTransactionToDelete(null)
}}>
</Button>
<Button variant="destructive" onClick={handleDeleteTransaction}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除持仓确认对话框 */} {/* 删除持仓确认对话框 */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">

View File

@ -62,6 +62,14 @@ export async function createTransaction(data: {
return res.json() return res.json()
} }
// 删除交易记录
export async function deleteTransaction(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/transactions?id=${id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Failed to delete transaction')
}
// 持仓 API // 持仓 API
export async function fetchPositions(accountId?: string): Promise<Position[]> { export async function fetchPositions(accountId?: string): Promise<Position[]> {
const searchParams = accountId ? `?accountId=${accountId}` : '' const searchParams = accountId ? `?accountId=${accountId}` : ''