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:
parent
08b0b129bc
commit
4bad47f83d
13
README.md
13
README.md
@ -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
9
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
212
src/app/page.tsx
212
src/app/page.tsx
@ -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">
|
||||||
|
|||||||
@ -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
26
src/types/yfinance.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user