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. 启动开发服务器
|
# 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股账户、加密货币账户
|
||||||
|
|||||||
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
121
src/app/page.tsx
121
src/app/page.tsx
@ -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">
|
||||||
|
|||||||
@ -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}` : ''
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user