v1.0.1 (2026-04-12)
- 🔧 账户名称优化:改为港股账户、美股账户、A股账户、加密货币账户 - ✨ 成交总额自动计算:根据数量 × 价格自动计算 - 🗑️ 删除持仓功能:支持通过卖出全部来删除持仓 - 🎨 Select 组件显示优化:修复了下拉框显示问题 - 📝 代码注释中文化
This commit is contained in:
parent
6f5bc03b88
commit
6ceb4a8e9b
10
README.md
10
README.md
@ -206,7 +206,7 @@ HTTPS_PROXY="http://192.168.48.171:7893"
|
||||
1. 点击右上角「**记录交易**」按钮
|
||||
2. 选择账户和交易类型
|
||||
3. 输入证券代码(支持搜索)
|
||||
4. 填写数量、价格、金额
|
||||
4. 填写数量、价格(成交总额自动计算)
|
||||
5. 点击「**确认记录**」
|
||||
6. 在确认对话框中核实信息,点击「**确认**」
|
||||
|
||||
@ -382,6 +382,14 @@ MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.1 (2026-04-12)
|
||||
|
||||
- 🔧 账户名称优化:改为港股账户、美股账户、A股账户、加密货币账户
|
||||
- ✨ 成交总额自动计算:根据数量 × 价格自动计算
|
||||
- 🗑️ 删除持仓功能:支持通过卖出全部来删除持仓
|
||||
- 🎨 Select 组件显示优化:修复了下拉框显示问题
|
||||
- 📝 代码注释中文化
|
||||
|
||||
### v1.0.0 (2026-04-12)
|
||||
|
||||
- ✨ 初始版本发布
|
||||
|
||||
@ -15,10 +15,10 @@ async function main() {
|
||||
|
||||
// 创建各市场的账户
|
||||
const accounts = [
|
||||
{ name: '富途港股', marketType: MarketType.HK, baseCurrency: 'HKD' },
|
||||
{ name: '老虎美股', marketType: MarketType.US, baseCurrency: 'USD' },
|
||||
{ name: 'A股通', marketType: MarketType.CN, baseCurrency: 'CNY' },
|
||||
{ name: '币安', marketType: MarketType.CRYPTO, baseCurrency: 'USDT' },
|
||||
{ name: '港股账户', marketType: MarketType.HK, baseCurrency: 'HKD' },
|
||||
{ name: '美股账户', marketType: MarketType.US, baseCurrency: 'USD' },
|
||||
{ name: 'A股账户', marketType: MarketType.CN, baseCurrency: 'CNY' },
|
||||
{ name: '加密货币账户', marketType: MarketType.CRYPTO, baseCurrency: 'USDT' },
|
||||
]
|
||||
|
||||
for (const acc of accounts) {
|
||||
|
||||
227
src/app/page.tsx
227
src/app/page.tsx
@ -17,7 +17,7 @@ import {
|
||||
import {
|
||||
Wallet, TrendingUp, TrendingDown, Plus, ArrowUpRight, ArrowDownRight,
|
||||
Bitcoin, Building2, Globe2, RefreshCw, DollarSign, Search, Check,
|
||||
Download, Upload,
|
||||
Download, Upload, Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchAccounts, fetchTransactions, fetchPositions,
|
||||
@ -86,10 +86,12 @@ export default function Dashboard() {
|
||||
const [securities, setSecurities] = useState<Security[]>([])
|
||||
const [analytics, setAnalytics] = useState<{ prices: Record<string, any>; positions: PositionAnalytics[]; summary: AnalyticsSummary } | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>('')
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>('')
|
||||
const [showTxDialog, setShowTxDialog] = useState(false)
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
const [showImportDialog, setShowImportDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [positionToDelete, setPositionToDelete] = useState<PositionAnalytics | null>(null)
|
||||
const [symbolSearch, setSymbolSearch] = useState('')
|
||||
const [filteredSecurities, setFilteredSecurities] = useState<Security[]>([])
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
@ -101,13 +103,16 @@ export default function Dashboard() {
|
||||
symbol: '',
|
||||
quantity: '',
|
||||
price: '',
|
||||
amount: '',
|
||||
amount: '', // 仅用于出金/入金
|
||||
fee: '0',
|
||||
currency: 'USD',
|
||||
notes: '',
|
||||
executedAt: new Date().toISOString().slice(0, 16),
|
||||
})
|
||||
|
||||
// 获取当前选中的账户对象
|
||||
const selectedAccount = accounts.find(a => a.id === selectedAccountId)
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
@ -128,15 +133,15 @@ export default function Dashboard() {
|
||||
setAnalytics(analyticsData)
|
||||
|
||||
// 默认选中第一个账户
|
||||
if (accountsData.length > 0 && !selectedAccount) {
|
||||
setSelectedAccount(accountsData[0].id)
|
||||
if (accountsData.length > 0 && !selectedAccountId) {
|
||||
setSelectedAccountId(accountsData[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('加载数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedAccount])
|
||||
}, [selectedAccountId])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@ -169,22 +174,30 @@ export default function Dashboard() {
|
||||
setFilteredSecurities([])
|
||||
}
|
||||
|
||||
// 当数量或价格变化时,自动计算成交总额
|
||||
const calculatedAmount = txForm.quantity && txForm.price
|
||||
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
|
||||
: ''
|
||||
|
||||
// 提交交易记录
|
||||
const handleSubmitTx = async () => {
|
||||
try {
|
||||
const accountId = selectedAccount || accounts[0]?.id
|
||||
if (!accountId) {
|
||||
if (!selectedAccountId) {
|
||||
toast.error('请先选择账户')
|
||||
return
|
||||
}
|
||||
|
||||
const amount = txForm.quantity && txForm.price
|
||||
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
|
||||
: txForm.amount
|
||||
|
||||
await createTransaction({
|
||||
accountId,
|
||||
accountId: selectedAccountId,
|
||||
type: txForm.type,
|
||||
symbol: txForm.symbol || undefined,
|
||||
quantity: txForm.quantity ? parseFloat(txForm.quantity) : undefined,
|
||||
price: txForm.price ? parseFloat(txForm.price) : undefined,
|
||||
amount: parseFloat(txForm.amount),
|
||||
amount: parseFloat(amount || '0'),
|
||||
fee: parseFloat(txForm.fee) || 0,
|
||||
currency: txForm.currency,
|
||||
notes: txForm.notes || undefined,
|
||||
@ -200,9 +213,48 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除持仓
|
||||
const handleDeletePosition = async () => {
|
||||
if (!positionToDelete) return
|
||||
|
||||
try {
|
||||
// 通过卖出全部来删除持仓
|
||||
const position = positions.find(p => p.symbol === positionToDelete.symbol)
|
||||
if (!position) {
|
||||
toast.error('未找到持仓记录')
|
||||
return
|
||||
}
|
||||
|
||||
await createTransaction({
|
||||
accountId: selectedAccountId || position.accountId,
|
||||
type: 'SELL',
|
||||
symbol: positionToDelete.symbol,
|
||||
quantity: parseFloat(positionToDelete.quantity.toString()),
|
||||
price: parseFloat(positionToDelete.currentPrice.toString()),
|
||||
amount: parseFloat((positionToDelete.quantity * positionToDelete.currentPrice).toFixed(2)),
|
||||
fee: 0,
|
||||
currency: positionToDelete.currency,
|
||||
notes: '删除持仓',
|
||||
executedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
toast.success('持仓已删除')
|
||||
setShowDeleteDialog(false)
|
||||
setPositionToDelete(null)
|
||||
loadData()
|
||||
} catch (error) {
|
||||
toast.error('删除持仓失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开删除确认对话框
|
||||
const openDeleteDialog = (pos: PositionAnalytics) => {
|
||||
setPositionToDelete(pos)
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
// 重置交易表单
|
||||
const resetTxForm = () => {
|
||||
const account = accounts.find(a => a.id === selectedAccount)
|
||||
setTxForm({
|
||||
type: 'BUY',
|
||||
symbol: '',
|
||||
@ -210,7 +262,7 @@ export default function Dashboard() {
|
||||
price: '',
|
||||
amount: '',
|
||||
fee: '0',
|
||||
currency: account?.baseCurrency || 'USD',
|
||||
currency: selectedAccount?.baseCurrency || 'USD',
|
||||
notes: '',
|
||||
executedAt: new Date().toISOString().slice(0, 16),
|
||||
})
|
||||
@ -224,18 +276,19 @@ export default function Dashboard() {
|
||||
|
||||
// 获取确认对话框显示信息
|
||||
const getConfirmInfo = () => {
|
||||
const account = accounts.find(a => a.id === selectedAccount)
|
||||
const typeLabel = transactionTypeLabels[txForm.type]
|
||||
const symbolLabel = txForm.symbol ? `${txForm.symbol} ` : ''
|
||||
const qtyLabel = txForm.quantity ? `${txForm.quantity}股 ` : ''
|
||||
const priceLabel = txForm.price ? `@ ${formatCurrency(parseFloat(txForm.price), txForm.currency)}` : ''
|
||||
const totalLabel = txForm.amount ? `合计 ${formatCurrency(parseFloat(txForm.amount), txForm.currency)}` : ''
|
||||
const total = txForm.quantity && txForm.price
|
||||
? (parseFloat(txForm.quantity) * parseFloat(txForm.price)).toFixed(2)
|
||||
: txForm.amount
|
||||
|
||||
return {
|
||||
account: account?.name || '未选择',
|
||||
market: account ? marketLabels[account.marketType] : '',
|
||||
account: selectedAccount?.name || '未选择',
|
||||
market: selectedAccount ? marketLabels[selectedAccount.marketType] : '',
|
||||
action: `${typeLabel} ${symbolLabel}${qtyLabel}${priceLabel}`,
|
||||
total: totalLabel,
|
||||
total: total ? `合计 ${formatCurrency(parseFloat(total), txForm.currency)}` : '',
|
||||
fee: txForm.fee && parseFloat(txForm.fee) > 0 ? `手续费: ${formatCurrency(parseFloat(txForm.fee), txForm.currency)}` : '',
|
||||
time: new Date(txForm.executedAt).toLocaleString('zh-CN'),
|
||||
}
|
||||
@ -290,7 +343,7 @@ export default function Dashboard() {
|
||||
|
||||
// 执行批量导入
|
||||
const handleExecuteImport = async () => {
|
||||
if (!selectedAccount) {
|
||||
if (!selectedAccountId) {
|
||||
toast.error('请先选择导入目标账户')
|
||||
return
|
||||
}
|
||||
@ -317,7 +370,7 @@ export default function Dashboard() {
|
||||
notes: tx.notes || null,
|
||||
executedAt: tx.executedAt,
|
||||
})),
|
||||
accountId: selectedAccount,
|
||||
accountId: selectedAccountId,
|
||||
}),
|
||||
})
|
||||
|
||||
@ -360,9 +413,16 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 账户选择下拉框 */}
|
||||
<Select value={selectedAccount} onValueChange={(v) => v && setSelectedAccount(v)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="选择账户" />
|
||||
<Select value={selectedAccountId} onValueChange={(v) => v && setSelectedAccountId(v)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="选择账户">
|
||||
{selectedAccount ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{marketIcons[selectedAccount.marketType]}
|
||||
<span>{selectedAccount.name}</span>
|
||||
</div>
|
||||
) : '选择账户'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts.map((acc) => (
|
||||
@ -538,6 +598,7 @@ export default function Dashboard() {
|
||||
<TableHead className="text-right">当前价</TableHead>
|
||||
<TableHead className="text-right">市值</TableHead>
|
||||
<TableHead className="text-right">盈亏</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -579,10 +640,15 @@ export default function Dashboard() {
|
||||
<span className="text-xs">({formatPercent(pos.pnlPercent)})</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDeleteDialog(pos)}>
|
||||
<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>
|
||||
@ -750,10 +816,12 @@ export default function Dashboard() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 账户选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account">账户</Label>
|
||||
<Select value={selectedAccount} onValueChange={(v) => v && setSelectedAccount(v)}>
|
||||
<SelectTrigger id="account">
|
||||
<SelectValue placeholder="选择账户" />
|
||||
<Label>账户</Label>
|
||||
<Select value={selectedAccountId} onValueChange={(v) => v && setSelectedAccountId(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{selectedAccount ? selectedAccount.name : '选择账户'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts.map((acc) => (
|
||||
@ -766,9 +834,9 @@ export default function Dashboard() {
|
||||
</div>
|
||||
{/* 交易类型选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">交易类型</Label>
|
||||
<Label>交易类型</Label>
|
||||
<Select value={txForm.type} onValueChange={(v) => setTxForm({ ...txForm, type: v as TransactionType })}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -785,11 +853,10 @@ export default function Dashboard() {
|
||||
{/* 证券代码搜索(买入/卖出/分红时显示) */}
|
||||
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="symbol">证券代码</Label>
|
||||
<Label>证券代码</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="symbol"
|
||||
className="pl-9"
|
||||
placeholder="搜索证券代码或名称"
|
||||
value={symbolSearch || txForm.symbol}
|
||||
@ -826,9 +893,8 @@ export default function Dashboard() {
|
||||
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">数量</Label>
|
||||
<Label>数量</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0"
|
||||
@ -837,9 +903,8 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">价格</Label>
|
||||
<Label>价格</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.00"
|
||||
@ -850,13 +915,28 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 金额输入 */}
|
||||
{/* 成交总额(自动计算) */}
|
||||
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
{['DEPOSIT', 'WITHDRAW'].includes(txForm.type) ? '金额' : '成交总额'}
|
||||
</Label>
|
||||
<Label>成交总额</Label>
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<span className="text-2xl font-bold">
|
||||
{calculatedAmount ? formatCurrency(parseFloat(calculatedAmount), txForm.currency) : '—'}
|
||||
</span>
|
||||
{txForm.quantity && txForm.price && (
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
({txForm.quantity} × {formatCurrency(parseFloat(txForm.price), txForm.currency)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 入金/出金金额输入 */}
|
||||
{['DEPOSIT', 'WITHDRAW'].includes(txForm.type) && (
|
||||
<div className="space-y-2">
|
||||
<Label>金额</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.00"
|
||||
@ -864,12 +944,12 @@ export default function Dashboard() {
|
||||
onChange={(e) => setTxForm({ ...txForm, amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 手续费输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fee">手续费</Label>
|
||||
<Label>手续费</Label>
|
||||
<Input
|
||||
id="fee"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.00"
|
||||
@ -880,9 +960,8 @@ export default function Dashboard() {
|
||||
|
||||
{/* 交易时间 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="executedAt">交易时间</Label>
|
||||
<Label>交易时间</Label>
|
||||
<Input
|
||||
id="executedAt"
|
||||
type="datetime-local"
|
||||
value={txForm.executedAt}
|
||||
onChange={(e) => setTxForm({ ...txForm, executedAt: e.target.value })}
|
||||
@ -891,9 +970,8 @@ export default function Dashboard() {
|
||||
|
||||
{/* 备注 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">备注</Label>
|
||||
<Label>备注</Label>
|
||||
<Input
|
||||
id="notes"
|
||||
placeholder="可选备注"
|
||||
value={txForm.notes}
|
||||
onChange={(e) => setTxForm({ ...txForm, notes: e.target.value })}
|
||||
@ -902,11 +980,15 @@ export default function Dashboard() {
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<Button className="w-full" onClick={() => {
|
||||
if (!selectedAccount) {
|
||||
if (!selectedAccountId) {
|
||||
toast.error('请先选择账户')
|
||||
return
|
||||
}
|
||||
if (!txForm.amount) {
|
||||
if (['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (!txForm.quantity || !txForm.price)) {
|
||||
toast.error('请输入数量和价格')
|
||||
return
|
||||
}
|
||||
if (['DEPOSIT', 'WITHDRAW'].includes(txForm.type) && !txForm.amount) {
|
||||
toast.error('请输入金额')
|
||||
return
|
||||
}
|
||||
@ -967,6 +1049,52 @@ export default function Dashboard() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除持仓确认对话框 */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除持仓</DialogTitle>
|
||||
</DialogHeader>
|
||||
{positionToDelete && (
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="text-center py-2">
|
||||
<div className="text-lg font-bold">{positionToDelete.symbol}</div>
|
||||
<div className="text-sm text-muted-foreground">{positionToDelete.name}</div>
|
||||
</div>
|
||||
<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-mono">{positionToDelete.quantity.toFixed(positionToDelete.isCrypto ? 6 : 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">当前价</span>
|
||||
<span className="font-mono">{formatCurrency(positionToDelete.currentPrice, positionToDelete.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">市值</span>
|
||||
<span className="font-mono font-bold">{formatCurrency(positionToDelete.marketValue, positionToDelete.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-destructive text-center">
|
||||
确定要删除此持仓吗?这将自动卖出全部持仓
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowDeleteDialog(false)
|
||||
setPositionToDelete(null)
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeletePosition}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 导入对话框 */}
|
||||
<Dialog open={showImportDialog} onOpenChange={setShowImportDialog}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
@ -987,9 +1115,8 @@ export default function Dashboard() {
|
||||
|
||||
{/* 文件选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="importFile">选择 CSV 文件</Label>
|
||||
<Label>选择 CSV 文件</Label>
|
||||
<Input
|
||||
id="importFile"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileImport}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user