v1.0.2 (2026-04-12)
- 🗑️ 删除交易记录功能:支持删除误添加的交易记录,自动回滚账户余额和持仓变化 - 💰 显示货币选择器:支持 CNY/USD/HKD 三种货币显示,默认 CNY - 🔄 实时货币转换:根据汇率自动转换总资产显示
This commit is contained in:
parent
6ceb4a8e9b
commit
1df2950097
@ -181,6 +181,9 @@ npx prisma db seed
|
||||
|
||||
# 5. 启动开发服务器
|
||||
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)
|
||||
|
||||
- 🔧 账户名称优化:改为港股账户、美股账户、A股账户、加密货币账户
|
||||
|
||||
@ -134,3 +134,87 @@ export async function POST(request: Request) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
121
src/app/page.tsx
121
src/app/page.tsx
@ -21,7 +21,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchAccounts, fetchTransactions, fetchPositions,
|
||||
fetchSecurities, createTransaction, formatCurrency, formatPercent,
|
||||
fetchSecurities, createTransaction, deleteTransaction, formatCurrency, formatPercent,
|
||||
marketLabels, transactionTypeLabels
|
||||
} from '@/lib/api'
|
||||
import { Account, Position, Transaction, MarketType, TransactionType, Security } from '@/types'
|
||||
@ -77,6 +77,20 @@ interface AnalyticsSummary {
|
||||
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() {
|
||||
// 状态定义
|
||||
@ -97,6 +111,13 @@ export default function Dashboard() {
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
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({
|
||||
type: 'BUY' as TransactionType,
|
||||
@ -253,6 +274,27 @@ export default function Dashboard() {
|
||||
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 = () => {
|
||||
setTxForm({
|
||||
@ -412,6 +454,17 @@ export default function Dashboard() {
|
||||
<h1 className="text-xl font-bold">投资持仓管理</h1>
|
||||
</div>
|
||||
<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)}>
|
||||
<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">
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<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">
|
||||
成本 {formatCurrency(analytics?.summary?.totalCostBasis || 0)}
|
||||
成本 {formatCurrency(convertCurrency(analytics?.summary?.totalCostBasis || 0, 'USD', displayCurrency), displayCurrency)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -478,7 +531,7 @@ export default function Dashboard() {
|
||||
<TrendingUp className="h-4 w-4 opacity-80" />
|
||||
</CardHeader>
|
||||
<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'}`}>
|
||||
{(analytics?.summary?.totalPnL || 0) >= 0 ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
|
||||
{formatPercent(analytics?.summary?.totalPnLPercent || 0)}
|
||||
@ -493,7 +546,7 @@ export default function Dashboard() {
|
||||
<DollarSign className="h-4 w-4 opacity-80" />
|
||||
</CardHeader>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -680,6 +733,7 @@ export default function Dashboard() {
|
||||
<TableHead className="text-right">价格</TableHead>
|
||||
<TableHead className="text-right">金额</TableHead>
|
||||
<TableHead>账户</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -715,10 +769,15 @@ export default function Dashboard() {
|
||||
<span className="text-sm">{tx.account?.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDeleteTxDialog(tx)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</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>
|
||||
</TableRow>
|
||||
@ -1049,6 +1108,54 @@ export default function Dashboard() {
|
||||
</DialogContent>
|
||||
</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}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
|
||||
@ -62,6 +62,14 @@ export async function createTransaction(data: {
|
||||
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
|
||||
export async function fetchPositions(accountId?: string): Promise<Position[]> {
|
||||
const searchParams = accountId ? `?accountId=${accountId}` : ''
|
||||
|
||||
Loading…
Reference in New Issue
Block a user