v1.0.4 (2026-04-12)

- 🐛 总资产计算修复:修复了多市场持仓汇总时货币转换错误的问题
- 🐛 浮动盈亏修复:修复了成本基数和市值货币单位不一致导致的错误
- 💹 持仓分析修复:确保所有持仓数据统一转换为 USD 后再汇总
- 📈 港股价格获取:改用腾讯行情接口(r_hk前缀)获取港股实时价格(小鹏汽车等)
- 📈 A股价格获取:改用腾讯行情接口(sz/sh前缀)获取A股实时价格(海尔智家、中证现金流ETF等)
- 📈 美股价格获取:改用腾讯行情接口(s_us前缀)获取美股实时价格
- 🔍 证券名称显示:搜索和选择证券时同时显示代码和中文名称
- 📋 证券数据库扩展:新增小鹏汽车(09868)、海尔智家(600690)、中证现金流ETF(159235)、Alphabet(GOOGL)
- ✏️ 交易流水编辑:支持编辑已创建的交易记录
- 💰 持仓货币随市场:持仓明细和分析中各市场使用对应货币显示(港股用HKD、A股用CNY、美股用USD)
This commit is contained in:
kennethcheng 2026-04-12 08:22:52 +08:00
parent 08b0b129bc
commit 4bad47f83d
9 changed files with 411 additions and 62 deletions

View File

@ -388,6 +388,19 @@ MIT License - 详见 [LICENSE](LICENSE) 文件
## 更新日志 ## 更新日志
### v1.0.4 (2026-04-12)
- 🐛 总资产计算修复:修复了多市场持仓汇总时货币转换错误的问题
- 🐛 浮动盈亏修复:修复了成本基数和市值货币单位不一致导致的错误
- 💹 持仓分析修复:确保所有持仓数据统一转换为 USD 后再汇总
- 📈 港股价格获取:改用腾讯行情接口(r_hk前缀)获取港股实时价格(小鹏汽车等)
- 📈 A股价格获取改用腾讯行情接口(sz/sh前缀)获取A股实时价格海尔智家、中证现金流ETF等
- 📈 美股价格获取:改用腾讯行情接口(s_us前缀)获取美股实时价格
- 🔍 证券名称显示:搜索和选择证券时同时显示代码和中文名称
- 📋 证券数据库扩展:新增小鹏汽车(09868)、海尔智家(600690)、中证现金流ETF(159235)、Alphabet(GOOGL)
- ✏️ 交易流水编辑:支持编辑已创建的交易记录
- 💰 持仓货币随市场持仓明细和分析中各市场使用对应货币显示港股用HKD、A股用CNY、美股用USD
### v1.0.3 (2026-04-12) ### v1.0.3 (2026-04-12)
- 🗑️ 删除交易记录功能:支持删除误添加的交易记录,自动回滚账户余额和持仓变化 - 🗑️ 删除交易记录功能:支持删除误添加的交易记录,自动回滚账户余额和持仓变化

9
package-lock.json generated
View File

@ -21,7 +21,8 @@
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"yfinance": "^0.0.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@ -10484,6 +10485,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/yfinance": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/yfinance/-/yfinance-0.0.6.tgz",
"integrity": "sha512-EcJrrcX+ZCpbhouGO6s5y7XRUHGBwFY19PKtUJ17vg1L0dSK2N59Ns0A/ZfLtpWC8WatWjen6gE+KW51bxthsA==",
"license": "MIT"
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -22,7 +22,8 @@
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"yfinance": "^0.0.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@ -66,10 +66,14 @@ async function main() {
// 初始化常见证券 // 初始化常见证券
const securities = [ const securities = [
{ symbol: '00700', name: '腾讯控股', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 }, { symbol: '00700', name: '腾讯控股', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: '09868', name: '小鹏汽车', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: '09988', name: '阿里巴巴', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 }, { symbol: '09988', name: '阿里巴巴', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'AAPL', name: 'Apple Inc.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 }, { symbol: 'AAPL', name: 'Apple Inc.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'MSFT', name: 'Microsoft Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 }, { symbol: 'MSFT', name: 'Microsoft Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'NVDA', name: 'NVIDIA Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 }, { symbol: 'NVDA', name: 'NVIDIA Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'GOOGL', name: 'Alphabet Inc.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: '600690', name: '海尔智家', market: MarketType.CN, currency: 'CNY', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: '159235', name: '中证现金流ETF', market: MarketType.CN, currency: 'CNY', lotSize: 100, priceDecimals: 3, qtyDecimals: 0 },
{ symbol: 'BTC', name: 'Bitcoin', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true }, { symbol: 'BTC', name: 'Bitcoin', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true },
{ symbol: 'ETH', name: 'Ethereum', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true }, { symbol: 'ETH', name: 'Ethereum', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true },
] ]

View File

@ -1,47 +1,108 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
// Alpha Vantage API 配置 // 腾讯行情接口
const ALPHA_VANTAGE_BASE_URL = 'https://www.alphavantage.co/query' const TENCENT_API_BASE = 'https://qt.gtimg.cn/q='
const API_KEY = process.env.ALPHA_VANTAGE_API_KEY || 'EZ4LLXN8DW0J4G7P'
interface StockQuote { // 将证券代码转换为腾讯接口格式
'01. symbol': string function toTencentSymbol(symbol: string, marketType: string): string {
'05. price': string switch (marketType) {
'09. change': string case 'CN':
'10. change percent': string // A股sz开头深圳sh开头上海
'06. volume': string if (symbol.startsWith('sz') || symbol.startsWith('sh')) {
'07. latest trading day': string return symbol
}
// 纯数字A股需要判断深圳还是上海
if (symbol.startsWith('6')) {
return `sh${symbol}`
}
return `sz${symbol}`
case 'HK':
// 港股:纯数字加 r_hk 前缀
if (symbol.startsWith('hk')) {
return `r_${symbol.toLowerCase()}`
}
return `r_hk${symbol}`
case 'US':
// 美股:纯字母加 s_us 前缀
if (symbol.startsWith('s_us')) {
return symbol.toLowerCase()
}
return `s_us${symbol.toUpperCase()}`
case 'CRYPTO':
// 加密货币暂时用 USDT 价格
return `usdt${symbol.toLowerCase()}`
default:
return symbol
}
} }
async function fetchStockQuote(symbol: string): Promise<{ price: number; change: number; changePercent: number } | null> { // 解析腾讯行情数据
// 格式: v_xxx="100~名称~代码~当前价~昨收价~..."
function parseTencentQuote(data: string): { price: number; change: number; changePercent: number } | null {
try { try {
const url = `${ALPHA_VANTAGE_BASE_URL}?function=GLOBAL_QUOTE&symbol=${symbol}&apikey=${API_KEY}` const match = data.match(/="([^"]+)"/)
if (!match) return null
const response = await fetch(url, { const fields = match[1].split('~')
next: { revalidate: 60 }, // 缓存1分钟 if (fields.length < 5) return null
})
if (!response.ok) return null const currentPrice = parseFloat(fields[3])
const previousClose = parseFloat(fields[4])
const data = await response.json() if (isNaN(currentPrice) || isNaN(previousClose) || previousClose === 0) {
const quote = data['Global Quote'] as StockQuote | undefined return null
if (quote && quote['05. price']) {
return {
price: parseFloat(quote['05. price']),
change: parseFloat(quote['09. change'] || '0'),
changePercent: parseFloat(quote['10. change percent']?.replace('%', '') || '0'),
}
} }
return null const change = currentPrice - previousClose
const changePercent = (change / previousClose) * 100
return {
price: currentPrice,
change,
changePercent,
}
} catch (error) { } catch (error) {
console.error(`Failed to fetch quote for ${symbol}:`, error) console.error('Failed to parse Tencent quote:', error)
return null return null
} }
} }
// 批量获取价格(腾讯接口支持批量查询,用逗号分隔)
async function fetchPrices(symbols: { symbol: string; marketType: string }[]): Promise<Record<string, { price: number; change: number; changePercent: number }>> {
if (symbols.length === 0) return {}
try {
const querySymbols = symbols.map(s => toTencentSymbol(s.symbol, s.marketType)).join(',')
const url = `${TENCENT_API_BASE}${querySymbols}`
const response = await fetch(url, {
next: { revalidate: 60 },
})
if (!response.ok) return {}
const text = await response.text()
const results: Record<string, { price: number; change: number; changePercent: number }> = {}
// 腾讯返回多行,每行一个股票
const lines = text.split('\n')
symbols.forEach((s, index) => {
if (lines[index]) {
const quote = parseTencentQuote(lines[index])
if (quote) {
results[s.symbol] = quote
}
}
})
return results
} catch (error) {
console.error('Failed to fetch prices from Tencent:', error)
return {}
}
}
// 获取持仓实时价格 // 获取持仓实时价格
export async function GET() { export async function GET() {
try { try {
@ -72,24 +133,13 @@ export async function GET() {
latestRates.map((r) => [`${r.fromCurrency}_${r.toCurrency}`, Number(r.rate)]) latestRates.map((r) => [`${r.fromCurrency}_${r.toCurrency}`, Number(r.rate)])
) )
// 4. 获取实时价格(只获取美股) // 4. 获取实时价格(使用腾讯行情接口)
const usSymbols = positions const symbolsWithMarket = positions.map((p) => ({
.filter((p) => p.account.marketType === 'US') symbol: p.symbol,
.map((p) => p.symbol) marketType: p.account.marketType,
}))
const priceResults: Record<string, { price: number; change: number; changePercent: number }> = {} const priceResults = await fetchPrices(symbolsWithMarket)
// 并行获取价格(限制并发数)
const batchSize = 3
for (let i = 0; i < usSymbols.length; i += batchSize) {
const batch = usSymbols.slice(i, i + batchSize)
const results = await Promise.all(batch.map((s) => fetchStockQuote(s)))
batch.forEach((symbol, index) => {
if (results[index]) {
priceResults[symbol] = results[index]!
}
})
}
// 5. 计算完整的持仓分析 // 5. 计算完整的持仓分析
const positionAnalytics = positions.map((pos) => { const positionAnalytics = positions.map((pos) => {
@ -104,6 +154,7 @@ export async function GET() {
const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0 const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0
const rate = rateMap.get(`${pos.currency}_USD`) || 1 const rate = rateMap.get(`${pos.currency}_USD`) || 1
const costBasisUSD = costBasis * rate
const marketValueUSD = marketValue * rate const marketValueUSD = marketValue * rate
const pnlUSD = pnl * rate const pnlUSD = pnl * rate
@ -118,6 +169,7 @@ export async function GET() {
change: quote?.change || 0, change: quote?.change || 0,
changePercent: quote?.changePercent || 0, changePercent: quote?.changePercent || 0,
costBasis, costBasis,
costBasisUSD,
marketValue, marketValue,
marketValueUSD, marketValueUSD,
pnl, pnl,
@ -128,18 +180,18 @@ export async function GET() {
} }
}) })
// 6. 汇总统计 // 6. 汇总统计(全部转换为 USD
const totalCostBasis = positionAnalytics.reduce((sum, p) => sum + p.costBasis, 0) const totalCostBasis = positionAnalytics.reduce((sum, p) => sum + p.costBasisUSD, 0)
const totalMarketValue = positionAnalytics.reduce((sum, p) => sum + p.marketValueUSD, 0) const totalMarketValue = positionAnalytics.reduce((sum, p) => sum + p.marketValueUSD, 0)
const totalPnL = totalMarketValue - totalCostBasis const totalPnL = totalMarketValue - totalCostBasis
const totalPnLPercent = totalCostBasis > 0 ? (totalPnL / totalCostBasis) * 100 : 0 const totalPnLPercent = totalCostBasis > 0 ? (totalPnL / totalCostBasis) * 100 : 0
// 7. 按市场分组 // 7. 按市场分组(全部使用 USD 计值)
const byMarket = positionAnalytics.reduce((acc, p) => { const byMarket = positionAnalytics.reduce((acc, p) => {
if (!acc[p.marketType]) { if (!acc[p.marketType]) {
acc[p.marketType] = { totalCost: 0, totalValue: 0, totalPnL: 0 } acc[p.marketType] = { totalCost: 0, totalValue: 0, totalPnL: 0 }
} }
acc[p.marketType].totalCost += p.costBasis acc[p.marketType].totalCost += p.costBasisUSD
acc[p.marketType].totalValue += p.marketValueUSD acc[p.marketType].totalValue += p.marketValueUSD
acc[p.marketType].totalPnL += p.pnlUSD acc[p.marketType].totalPnL += p.pnlUSD
return acc return acc

View File

@ -135,6 +135,39 @@ export async function POST(request: Request) {
} }
} }
// 更新交易记录
export async function PATCH(request: Request) {
try {
const body = await request.json()
const { id, type, symbol, quantity, price, amount, fee, currency, notes, executedAt } = body
if (!id) {
return NextResponse.json({ error: 'Transaction ID is required' }, { status: 400 })
}
const updatePayload: Record<string, any> = {}
if (type !== undefined) updatePayload.type = type
if (symbol !== undefined) updatePayload.symbol = symbol || null
if (quantity !== undefined) updatePayload.quantity = quantity ? new Prisma.Decimal(quantity) : null
if (price !== undefined) updatePayload.price = price ? new Prisma.Decimal(price) : null
if (amount !== undefined) updatePayload.amount = amount ? new Prisma.Decimal(amount) : null
if (fee !== undefined) updatePayload.fee = new Prisma.Decimal(fee)
if (currency !== undefined) updatePayload.currency = currency
if (notes !== undefined) updatePayload.notes = notes
if (executedAt !== undefined) updatePayload.executedAt = new Date(executedAt)
const transaction = await prisma.transaction.update({
where: { id },
data: updatePayload,
})
return NextResponse.json(transaction)
} catch (error) {
console.error('Update transaction error:', error)
return NextResponse.json({ error: 'Failed to update transaction' }, { status: 500 })
}
}
// 删除交易记录 // 删除交易记录
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {

View File

@ -17,11 +17,11 @@ import {
import { import {
Wallet, TrendingUp, TrendingDown, Plus, ArrowUpRight, ArrowDownRight, Wallet, TrendingUp, TrendingDown, Plus, ArrowUpRight, ArrowDownRight,
Bitcoin, Building2, Globe2, RefreshCw, DollarSign, Search, Check, Bitcoin, Building2, Globe2, RefreshCw, DollarSign, Search, Check,
Download, Upload, Trash2, Download, Upload, Trash2, Edit,
} from 'lucide-react' } from 'lucide-react'
import { import {
fetchAccounts, fetchTransactions, fetchPositions, fetchAccounts, fetchTransactions, fetchPositions,
fetchSecurities, createTransaction, deleteTransaction, formatCurrency, formatPercent, fetchSecurities, createTransaction, deleteTransaction, updateTransaction, 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'
@ -118,6 +118,21 @@ export default function Dashboard() {
const [transactionToDelete, setTransactionToDelete] = useState<Transaction | null>(null) const [transactionToDelete, setTransactionToDelete] = useState<Transaction | null>(null)
const [showDeleteTxDialog, setShowDeleteTxDialog] = useState(false) const [showDeleteTxDialog, setShowDeleteTxDialog] = useState(false)
// 交易记录编辑状态
const [transactionToEdit, setTransactionToEdit] = useState<Transaction | null>(null)
const [showEditTxDialog, setShowEditTxDialog] = useState(false)
const [editTxForm, setEditTxForm] = useState({
type: 'BUY' as TransactionType,
symbol: '',
quantity: '',
price: '',
amount: '',
fee: '0',
currency: 'USD',
notes: '',
executedAt: new Date().toISOString().slice(0, 16),
})
// 交易表单状态 // 交易表单状态
const [txForm, setTxForm] = useState({ const [txForm, setTxForm] = useState({
type: 'BUY' as TransactionType, type: 'BUY' as TransactionType,
@ -181,6 +196,13 @@ export default function Dashboard() {
} }
}, [symbolSearch, securities]) }, [symbolSearch, securities])
// 获取证券显示文本(代码+名称)
const getSecurityDisplayText = (symbol: string) => {
if (!symbol) return ''
const sec = securities.find(s => s.symbol === symbol)
return sec ? `${sec.symbol} ${sec.name}` : symbol
}
// 选择证券后自动填充价格和币种 // 选择证券后自动填充价格和币种
const handleSelectSecurity = (symbol: string) => { const handleSelectSecurity = (symbol: string) => {
const price = analytics?.prices[symbol]?.price || 0 const price = analytics?.prices[symbol]?.price || 0
@ -295,6 +317,52 @@ export default function Dashboard() {
} }
} }
// 打开编辑交易记录对话框
const openEditTxDialog = (tx: Transaction) => {
setTransactionToEdit(tx)
setEditTxForm({
type: tx.type,
symbol: tx.symbol || '',
quantity: tx.quantity ? parseFloat(tx.quantity.toString()).toString() : '',
price: tx.price ? parseFloat(tx.price.toString()).toString() : '',
amount: parseFloat(tx.amount.toString()).toString(),
fee: parseFloat(tx.fee.toString()).toString(),
currency: tx.currency,
notes: tx.notes || '',
executedAt: new Date(tx.executedAt).toISOString().slice(0, 16),
})
setShowEditTxDialog(true)
}
// 提交编辑交易记录
const handleEditTransaction = async () => {
if (!transactionToEdit) return
try {
const amount = editTxForm.quantity && editTxForm.price
? (parseFloat(editTxForm.quantity) * parseFloat(editTxForm.price)).toFixed(2)
: editTxForm.amount
await updateTransaction(transactionToEdit.id, {
type: editTxForm.type,
symbol: editTxForm.symbol || undefined,
quantity: editTxForm.quantity ? parseFloat(editTxForm.quantity) : undefined,
price: editTxForm.price ? parseFloat(editTxForm.price) : undefined,
amount: parseFloat(amount || '0'),
fee: parseFloat(editTxForm.fee) || 0,
currency: editTxForm.currency,
notes: editTxForm.notes || undefined,
executedAt: editTxForm.executedAt,
})
toast.success('交易记录已更新')
setShowEditTxDialog(false)
setTransactionToEdit(null)
loadData()
} catch (error) {
toast.error('更新交易记录失败')
}
}
// 重置交易表单 // 重置交易表单
const resetTxForm = () => { const resetTxForm = () => {
setTxForm({ setTxForm({
@ -615,12 +683,13 @@ export default function Dashboard() {
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
{marketIcons[pos.marketType as MarketType]} {marketIcons[pos.marketType as MarketType]}
<span className="font-medium">{pos.symbol}</span> <span className="font-medium">{pos.symbol}</span>
<span className="text-xs text-muted-foreground">{pos.currency}</span>
</div> </div>
<div className="text-lg font-bold">{formatCurrency(pos.marketValueUSD)}</div> <div className="text-lg font-bold">{formatCurrency(pos.marketValue, pos.currency)}</div>
<Separator className="my-2" /> <Separator className="my-2" />
<div className={`text-sm flex items-center gap-1 ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}> <div className={`text-sm flex items-center gap-1 ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />} {pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{formatCurrency(Math.abs(pos.pnlUSD))} ({formatPercent(pos.pnlPercent)}) {formatCurrency(Math.abs(pos.pnl), pos.currency)} ({formatPercent(pos.pnlPercent)})
</div> </div>
</div> </div>
))} ))}
@ -770,9 +839,14 @@ export default function Dashboard() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="sm" onClick={() => openDeleteTxDialog(tx)}> <div className="flex items-center gap-1">
<Trash2 className="h-4 w-4 text-destructive" /> <Button variant="ghost" size="sm" onClick={() => openEditTxDialog(tx)}>
</Button> <Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openDeleteTxDialog(tx)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) : ( )) : (
@ -808,10 +882,11 @@ export default function Dashboard() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{marketIcons[pos.marketType as MarketType]} {marketIcons[pos.marketType as MarketType]}
<span className="font-medium">{pos.symbol}</span> <span className="font-medium">{pos.symbol}</span>
<span className="text-xs text-muted-foreground">{pos.currency}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">{percent.toFixed(1)}%</span> <span className="text-muted-foreground">{percent.toFixed(1)}%</span>
<span className="font-mono">{formatCurrency(pos.marketValueUSD)}</span> <span className="font-mono">{formatCurrency(pos.marketValue, pos.currency)}</span>
</div> </div>
</div> </div>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-2 bg-muted rounded-full overflow-hidden">
@ -851,7 +926,7 @@ export default function Dashboard() {
<div className={`text-right font-mono ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}> <div className={`text-right font-mono ${pos.pnl >= 0 ? 'text-green-500' : 'text-red-500'}`}>
<div className="flex items-center gap-1 justify-end"> <div className="flex items-center gap-1 justify-end">
{pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />} {pos.pnl >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{formatCurrency(Math.abs(pos.pnlUSD))} {formatCurrency(Math.abs(pos.pnl), pos.currency)}
</div> </div>
<div className="text-xs">{formatPercent(pos.pnlPercent)}</div> <div className="text-xs">{formatPercent(pos.pnlPercent)}</div>
</div> </div>
@ -926,7 +1001,7 @@ export default function Dashboard() {
<Input <Input
className="pl-9" className="pl-9"
placeholder="搜索证券代码或名称" placeholder="搜索证券代码或名称"
value={symbolSearch || txForm.symbol} value={symbolSearch || getSecurityDisplayText(txForm.symbol)}
onChange={(e) => { onChange={(e) => {
setSymbolSearch(e.target.value) setSymbolSearch(e.target.value)
setTxForm({ ...txForm, symbol: e.target.value.toUpperCase() }) setTxForm({ ...txForm, symbol: e.target.value.toUpperCase() })
@ -1116,6 +1191,123 @@ export default function Dashboard() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 编辑交易记录对话框 */}
<Dialog open={showEditTxDialog} onOpenChange={setShowEditTxDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
{/* 交易类型选择 */}
<div className="space-y-2">
<Label></Label>
<Select value={editTxForm.type} onValueChange={(v) => setEditTxForm({ ...editTxForm, type: v as TransactionType })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(['BUY', 'SELL', 'DEPOSIT', 'WITHDRAW', 'DIVIDEND'] as TransactionType[]).map((t) => (
<SelectItem key={t} value={t}>
{transactionTypeLabels[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 交易时间 */}
<div className="space-y-2">
<Label></Label>
<Input
type="datetime-local"
value={editTxForm.executedAt}
onChange={(e) => setEditTxForm({ ...editTxForm, executedAt: e.target.value })}
/>
</div>
</div>
{/* 证券代码(买入/卖出/分红时显示) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && (
<div className="space-y-2">
<Label></Label>
<Input
placeholder="输入证券代码"
value={editTxForm.symbol}
onChange={(e) => setEditTxForm({ ...editTxForm, symbol: e.target.value.toUpperCase() })}
/>
</div>
)}
{/* 数量和价格输入(买入/卖出/分红时显示) */}
{['BUY', 'SELL', 'DIVIDEND'].includes(editTxForm.type) && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0"
value={editTxForm.quantity}
onChange={(e) => setEditTxForm({ ...editTxForm, quantity: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={editTxForm.price}
onChange={(e) => setEditTxForm({ ...editTxForm, price: e.target.value })}
/>
</div>
</div>
)}
{/* 金额输入(入金/出金时显示) */}
{['DEPOSIT', 'WITHDRAW'].includes(editTxForm.type) && (
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={editTxForm.amount}
onChange={(e) => setEditTxForm({ ...editTxForm, amount: e.target.value })}
/>
</div>
)}
{/* 手续费输入 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
step="any"
placeholder="0.00"
value={editTxForm.fee}
onChange={(e) => setEditTxForm({ ...editTxForm, fee: e.target.value })}
/>
</div>
{/* 备注 */}
<div className="space-y-2">
<Label></Label>
<Input
placeholder="可选备注"
value={editTxForm.notes}
onChange={(e) => setEditTxForm({ ...editTxForm, notes: e.target.value })}
/>
</div>
{/* 提交按钮 */}
<Button className="w-full" onClick={handleEditTransaction}>
</Button>
</div>
</DialogContent>
</Dialog>
{/* 删除交易记录确认对话框 */} {/* 删除交易记录确认对话框 */}
<Dialog open={showDeleteTxDialog} onOpenChange={setShowDeleteTxDialog}> <Dialog open={showDeleteTxDialog} onOpenChange={setShowDeleteTxDialog}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">

View File

@ -70,6 +70,27 @@ export async function deleteTransaction(id: string): Promise<void> {
if (!res.ok) throw new Error('Failed to delete transaction') if (!res.ok) throw new Error('Failed to delete transaction')
} }
// 更新交易记录
export async function updateTransaction(id: string, data: {
type?: TransactionType
symbol?: string
quantity?: number
price?: number
amount?: number
fee?: number
currency?: string
notes?: string
executedAt?: string
}): Promise<Transaction> {
const res = await fetch(`${API_BASE}/transactions`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...data }),
})
if (!res.ok) throw new Error('Failed to update transaction')
return res.json()
}
// 持仓 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}` : ''

26
src/types/yfinance.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
declare module 'yfinance' {
interface TickerInfo {
regularMarketPrice?: number
regularMarketPreviousClose?: number
symbol?: string
shortName?: string
longName?: string
currency?: string
marketCap?: number
fiftyTwoWeekHigh?: number
fiftyTwoWeekLow?: number
}
interface Ticker {
info: Promise<TickerInfo>
history: (options?: { period?: string; interval?: string }) => Promise<any>
}
interface YFinance {
(symbol: string): Ticker
default: (symbol: string) => Ticker
}
const yfinance: YFinance
export default yfinance
}