v1.0.1 (2026-04-12)

- 🔧 账户名称优化:改为港股账户、美股账户、A股账户、加密货币账户
-  成交总额自动计算:根据数量 × 价格自动计算
- 🗑️ 删除持仓功能:支持通过卖出全部来删除持仓
- 🎨 Select 组件显示优化:修复了下拉框显示问题
- 📝 代码注释中文化
This commit is contained in:
kennethcheng 2026-04-12 05:37:59 +08:00
parent 6f5bc03b88
commit 6ceb4a8e9b
3 changed files with 199 additions and 64 deletions

View File

@ -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)
- ✨ 初始版本发布

View File

@ -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) {

View File

@ -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,26 +915,41 @@ export default function Dashboard() {
</div>
)}
{/* 金额输入 */}
<div className="space-y-2">
<Label htmlFor="amount">
{['DEPOSIT', 'WITHDRAW'].includes(txForm.type) ? '金额' : '成交总额'}
</Label>
<Input
id="amount"
type="number"
step="any"
placeholder="0.00"
value={txForm.amount}
onChange={(e) => setTxForm({ ...txForm, amount: e.target.value })}
/>
</div>
{/* 成交总额(自动计算) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(txForm.type) && (
<div className="space-y-2">
<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
type="number"
step="any"
placeholder="0.00"
value={txForm.amount}
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}