This commit is contained in:
kennethcheng 2026-04-12 05:09:10 +08:00
parent 344018063d
commit f6436db200
22 changed files with 2979 additions and 87 deletions

View File

@ -1,7 +1,11 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ allowedDevOrigins: ['192.168.50.50', 'localhost'],
env: {
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
},
}; };
export default nextConfig; export default nextConfig;

22
package-lock.json generated
View File

@ -14,10 +14,12 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.3", "next": "16.2.3",
"next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"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"
}, },
@ -7658,6 +7660,16 @@
} }
} }
}, },
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz",
@ -9281,6 +9293,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",

View File

@ -15,10 +15,12 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.3", "next": "16.2.3",
"next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"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"
}, },

View File

@ -0,0 +1,164 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// Alpha Vantage API 配置
const ALPHA_VANTAGE_BASE_URL = 'https://www.alphavantage.co/query'
const API_KEY = process.env.ALPHA_VANTAGE_API_KEY || 'EZ4LLXN8DW0J4G7P'
interface StockQuote {
'01. symbol': string
'05. price': string
'09. change': string
'10. change percent': string
'06. volume': string
'07. latest trading day': string
}
async function fetchStockQuote(symbol: string): Promise<{ price: number; change: number; changePercent: number } | null> {
try {
const url = `${ALPHA_VANTAGE_BASE_URL}?function=GLOBAL_QUOTE&symbol=${symbol}&apikey=${API_KEY}`
const response = await fetch(url, {
next: { revalidate: 60 }, // 缓存1分钟
})
if (!response.ok) return null
const data = await response.json()
const quote = data['Global Quote'] as StockQuote | undefined
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
} catch (error) {
console.error(`Failed to fetch quote for ${symbol}:`, error)
return null
}
}
// 获取持仓实时价格
export async function GET() {
try {
// 1. 获取所有持仓
const positions = await prisma.position.findMany({
where: { account: { user: { email: 'demo@portfolio.local' } } },
include: {
account: { select: { name: true, marketType: true, baseCurrency: true } },
},
})
if (positions.length === 0) {
return NextResponse.json({ prices: {}, quotes: {} })
}
// 2. 获取证券信息
const symbols = positions.map((p) => p.symbol)
const securities = await prisma.security.findMany({
where: { symbol: { in: symbols } },
})
const securityMap = new Map(securities.map((s) => [s.symbol, s]))
// 3. 获取最新汇率
const latestRates = await prisma.exchangeRate.findMany({
orderBy: { effectiveDate: 'desc' },
})
const rateMap = new Map(
latestRates.map((r) => [`${r.fromCurrency}_${r.toCurrency}`, Number(r.rate)])
)
// 4. 获取实时价格(只获取美股)
const usSymbols = positions
.filter((p) => p.account.marketType === 'US')
.map((p) => p.symbol)
const priceResults: Record<string, { price: number; change: number; changePercent: number }> = {}
// 并行获取价格(限制并发数)
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. 计算完整的持仓分析
const positionAnalytics = positions.map((pos) => {
const security = securityMap.get(pos.symbol)
const quote = priceResults[pos.symbol]
const currentPrice = quote?.price || Number(pos.averageCost)
const qty = Number(pos.quantity)
const avgCost = Number(pos.averageCost)
const costBasis = qty * avgCost
const marketValue = qty * currentPrice
const pnl = marketValue - costBasis
const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0
const rate = rateMap.get(`${pos.currency}_USD`) || 1
const marketValueUSD = marketValue * rate
const pnlUSD = pnl * rate
return {
symbol: pos.symbol,
name: security?.name || pos.symbol,
marketType: pos.account.marketType,
accountName: pos.account.name,
quantity: qty,
avgCost,
currentPrice,
change: quote?.change || 0,
changePercent: quote?.changePercent || 0,
costBasis,
marketValue,
marketValueUSD,
pnl,
pnlPercent,
pnlUSD,
currency: pos.currency,
isCrypto: security?.isCrypto || false,
}
})
// 6. 汇总统计
const totalCostBasis = positionAnalytics.reduce((sum, p) => sum + p.costBasis, 0)
const totalMarketValue = positionAnalytics.reduce((sum, p) => sum + p.marketValueUSD, 0)
const totalPnL = totalMarketValue - totalCostBasis
const totalPnLPercent = totalCostBasis > 0 ? (totalPnL / totalCostBasis) * 100 : 0
// 7. 按市场分组
const byMarket = positionAnalytics.reduce((acc, p) => {
if (!acc[p.marketType]) {
acc[p.marketType] = { totalCost: 0, totalValue: 0, totalPnL: 0 }
}
acc[p.marketType].totalCost += p.costBasis
acc[p.marketType].totalValue += p.marketValueUSD
acc[p.marketType].totalPnL += p.pnlUSD
return acc
}, {} as Record<string, { totalCost: number; totalValue: number; totalPnL: number }>)
return NextResponse.json({
prices: priceResults,
positions: positionAnalytics,
summary: {
totalCostBasis,
totalMarketValue,
totalPnL,
totalPnLPercent,
positionCount: positions.length,
},
byMarket,
})
} catch (error) {
console.error('Analytics error:', error)
return NextResponse.json({ error: 'Failed to fetch analytics' }, { status: 500 })
}
}

View File

@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
// 导入交易记录
export async function POST(request: Request) {
try {
const body = await request.json()
const { transactions, accountId } = body
if (!transactions || !Array.isArray(transactions)) {
return NextResponse.json({ error: 'Invalid transactions data' }, { status: 400 })
}
if (!accountId) {
return NextResponse.json({ error: 'Account ID is required' }, { status: 400 })
}
const results = {
success: 0,
failed: 0,
errors: [] as { row: number; error: string }[],
}
// 逐条处理交易
for (let i = 0; i < transactions.length; i++) {
const tx = transactions[i]
try {
await prisma.$transaction(async (txClient) => {
// 1. 创建交易记录
await txClient.transaction.create({
data: {
accountId,
type: tx.type,
symbol: tx.symbol || null,
quantity: tx.quantity ? new Prisma.Decimal(tx.quantity) : null,
price: tx.price ? new Prisma.Decimal(tx.price) : null,
amount: new Prisma.Decimal(tx.amount),
fee: new Prisma.Decimal(tx.fee || 0),
currency: tx.currency,
exchangeRate: tx.exchangeRate ? new Prisma.Decimal(tx.exchangeRate) : null,
notes: tx.notes || null,
executedAt: new Date(tx.executedAt),
},
})
// 2. 更新账户余额
if (tx.type === 'DEPOSIT' || tx.type === 'WITHDRAW') {
const balanceChange = tx.type === 'DEPOSIT'
? new Prisma.Decimal(tx.amount)
: new Prisma.Decimal(tx.amount).negated()
await txClient.account.update({
where: { id: accountId },
data: { balance: { increment: balanceChange } },
})
}
// 3. 更新持仓
if ((tx.type === 'BUY' || tx.type === 'SELL') && tx.symbol && tx.quantity && tx.price) {
const position = await txClient.position.findUnique({
where: { accountId_symbol: { accountId, symbol: tx.symbol } },
})
if (tx.type === 'BUY') {
const buyAmount = new Prisma.Decimal(tx.quantity).times(new Prisma.Decimal(tx.price))
if (position) {
const newQty = position.quantity.plus(new Prisma.Decimal(tx.quantity))
const newCost = position.averageCost.times(position.quantity).plus(buyAmount).div(newQty)
await txClient.position.update({
where: { accountId_symbol: { accountId, symbol: tx.symbol } },
data: { quantity: newQty, averageCost: newCost },
})
} else {
await txClient.position.create({
data: {
accountId,
symbol: tx.symbol,
quantity: new Prisma.Decimal(tx.quantity),
averageCost: new Prisma.Decimal(tx.price),
currency: tx.currency,
},
})
}
} else if (tx.type === 'SELL' && position) {
const newQty = position.quantity.minus(new Prisma.Decimal(tx.quantity))
await txClient.position.update({
where: { accountId_symbol: { accountId, symbol: tx.symbol } },
data: { quantity: newQty },
})
}
}
})
results.success++
} catch (error) {
results.failed++
results.errors.push({
row: i + 1,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return NextResponse.json(results)
} catch (error) {
console.error('Import error:', error)
return NextResponse.json({ error: 'Failed to import transactions' }, { status: 500 })
}
}

View File

@ -1,33 +1,23 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"
import "./globals.css"; import { Toaster } from "@/components/ui/sonner"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "个人投资持仓管理",
description: "Generated by create next app", description: "管理您的多市场投资组合",
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
return ( return (
<html <html lang="zh-CN" className="dark">
lang="en" <body className="min-h-screen bg-background text-foreground antialiased">
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} {children}
> <Toaster />
<body className="min-h-full flex flex-col">{children}</body> </body>
</html> </html>
); )
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

103
src/components/ui/card.tsx Normal file
View File

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

116
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

151
src/lib/api.ts Normal file
View File

@ -0,0 +1,151 @@
import { Account, Transaction, Position, Security, DashboardStats, TransactionType, MarketType } from '@/types'
const API_BASE = '/api'
// 账户 API
export async function fetchAccounts(): Promise<Account[]> {
const res = await fetch(`${API_BASE}/accounts`)
if (!res.ok) throw new Error('Failed to fetch accounts')
return res.json()
}
export async function createAccount(data: {
name: string
marketType: MarketType
baseCurrency: string
}): Promise<Account> {
const res = await fetch(`${API_BASE}/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create account')
return res.json()
}
// 交易 API
export async function fetchTransactions(params?: {
accountId?: string
page?: number
limit?: number
}) {
const searchParams = new URLSearchParams()
if (params?.accountId) searchParams.set('accountId', params.accountId)
if (params?.page) searchParams.set('page', params.page.toString())
if (params?.limit) searchParams.set('limit', params.limit.toString())
const res = await fetch(`${API_BASE}/transactions?${searchParams}`)
if (!res.ok) throw new Error('Failed to fetch transactions')
return res.json()
}
export async function createTransaction(data: {
accountId: string
type: TransactionType
symbol?: string
quantity?: number
price?: number
amount: number
fee?: number
networkFee?: number
currency: string
exchangeRate?: number
notes?: string
executedAt: string
}): Promise<Transaction> {
const res = await fetch(`${API_BASE}/transactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create transaction')
return res.json()
}
// 持仓 API
export async function fetchPositions(accountId?: string): Promise<Position[]> {
const searchParams = accountId ? `?accountId=${accountId}` : ''
const res = await fetch(`${API_BASE}/positions${searchParams}`)
if (!res.ok) throw new Error('Failed to fetch positions')
return res.json()
}
// 仪表盘 API
export async function fetchDashboardStats(): Promise<DashboardStats> {
const res = await fetch(`${API_BASE}/dashboard/stats`)
if (!res.ok) throw new Error('Failed to fetch dashboard stats')
return res.json()
}
// 证券 API
export async function fetchSecurities(params?: {
market?: MarketType
search?: string
}): Promise<Security[]> {
const searchParams = new URLSearchParams()
if (params?.market) searchParams.set('market', params.market)
if (params?.search) searchParams.set('search', params.search)
const res = await fetch(`${API_BASE}/securities?${searchParams}`)
if (!res.ok) throw new Error('Failed to fetch securities')
return res.json()
}
export async function createSecurity(data: {
symbol: string
name: string
market: MarketType
currency: string
lotSize?: number
priceDecimals?: number
qtyDecimals?: number
isCrypto?: boolean
}): Promise<Security> {
const res = await fetch(`${API_BASE}/securities`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create security')
return res.json()
}
// 格式化货币
export function formatCurrency(amount: number, currency: string = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
// 格式化数量
export function formatQuantity(qty: number, decimals: number = 2): string {
return qty.toFixed(decimals)
}
// 格式化百分比
export function formatPercent(percent: number): string {
const sign = percent >= 0 ? '+' : ''
return `${sign}${percent.toFixed(2)}%`
}
// 市场类型显示
export const marketLabels: Record<MarketType, string> = {
US: '美股',
CN: 'A股',
HK: '港股',
CRYPTO: '加密货币',
}
// 交易类型显示
export const transactionTypeLabels: Record<TransactionType, string> = {
DEPOSIT: '入金',
WITHDRAW: '出金',
BUY: '买入',
SELL: '卖出',
DIVIDEND: '分红',
INTEREST: '利息',
FEE: '费用',
}

193
src/lib/import-export.ts Normal file
View File

@ -0,0 +1,193 @@
import { Transaction, Position, Account } from '@/types'
// 导出交易记录为 CSV
export function exportTransactionsToCSV(transactions: Transaction[]): string {
const headers = [
'时间',
'类型',
'证券代码',
'数量',
'价格',
'金额',
'手续费',
'币种',
'汇率',
'备注',
'账户',
'市场',
]
const rows = transactions.map(tx => [
new Date(tx.executedAt).toLocaleString('zh-CN'),
tx.type,
tx.symbol || '',
tx.quantity || '',
tx.price || '',
tx.amount,
tx.fee,
tx.currency,
tx.exchangeRate || '',
tx.notes || '',
tx.account?.name || '',
tx.account?.marketType || '',
])
return [headers, ...rows]
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n')
}
// 导出持仓为 CSV
export function exportPositionsToCSV(positions: Position[]): string {
const headers = [
'证券代码',
'名称',
'市场',
'账户',
'数量',
'平均成本',
'当前价',
'市值',
'币种',
'盈亏',
'盈亏比例',
]
const rows = positions.map(pos => [
pos.symbol,
pos.name || pos.symbol,
pos.marketType || '',
pos.accountName || '',
pos.quantity,
pos.averageCost,
pos.currentPrice || pos.averageCost,
pos.value?.toFixed(2) || '0',
pos.currency,
pos.pnl?.toFixed(2) || '0',
pos.pnlPercent?.toFixed(2) || '0',
])
return [headers, ...rows]
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n')
}
// 下载 CSV 文件
export function downloadCSV(content: string, filename: string) {
const blob = new Blob(['\ufeff' + content], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 导入模板 CSV
export const TRANSACTION_IMPORT_TEMPLATE = `时间,类型,证券代码,数量,价格,金额,手续费,币种,备注
2024-01-15 10:30:00,BUY,AAPL,10,185.50,1855.00,1.00,USD,
2024-01-20 14:20:00,DIVIDEND,AAPL,,,15.50,0.00,USD,
2024-02-01 09:45:00,SELL,AAPL,5,190.00,950.00,1.00,USD,5`
export const ACCOUNT_IMPORT_TEMPLATE = `账户名称,市场类型,基础货币
,HK,HKD
,US,USD`
// 解析导入的 CSV
export function parseImportCSV(content: string): { headers: string[]; rows: string[][] } {
const lines = content.trim().split('\n')
const headers = parseCSVLine(lines[0])
const rows = lines.slice(1).map(line => parseCSVLine(line))
return { headers, rows }
}
function parseCSVLine(line: string): string[] {
const result: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"'
i++
} else {
inQuotes = !inQuotes
}
} else if (char === ',' && !inQuotes) {
result.push(current.trim())
current = ''
} else {
current += char
}
}
result.push(current.trim())
return result
}
// 验证导入的交易数据
export interface ImportTransaction {
executedAt: string
type: string
symbol?: string
quantity?: string
price?: string
amount: string
fee?: string
currency: string
notes?: string
errors: string[]
}
export function validateImportTransaction(row: string[], headers: string[]): ImportTransaction {
const errors: string[] = []
const getValue = (header: string): string => {
const index = headers.indexOf(header)
return index >= 0 ? row[index] || '' : ''
}
const executedAt = getValue('时间')
const type = getValue('类型')
const symbol = getValue('证券代码')
const quantity = getValue('数量')
const price = getValue('价格')
const amount = getValue('金额')
const fee = getValue('手续费') || '0'
const currency = getValue('币种')
const notes = getValue('备注')
// 验证必填字段
if (!executedAt) errors.push('缺少交易时间')
if (!type) errors.push('缺少交易类型')
if (!amount) errors.push('缺少金额')
if (!currency) errors.push('缺少币种')
// 验证交易类型
const validTypes = ['DEPOSIT', 'WITHDRAW', 'BUY', 'SELL', 'DIVIDEND', 'INTEREST', 'FEE']
if (type && !validTypes.includes(type.toUpperCase())) {
errors.push(`无效的交易类型: ${type}`)
}
// 验证数值字段
if (amount && isNaN(parseFloat(amount))) errors.push('金额必须是数字')
if (quantity && isNaN(parseFloat(quantity))) errors.push('数量必须是数字')
if (price && isNaN(parseFloat(price))) errors.push('价格必须是数字')
return {
executedAt,
type: type.toUpperCase(),
symbol,
quantity,
price,
amount,
fee,
currency,
notes,
errors,
}
}

76
src/lib/price-service.ts Normal file
View File

@ -0,0 +1,76 @@
// Alpha Vantage 价格服务
const ALPHA_VANTAGE_BASE_URL = 'https://www.alphavantage.co/query'
const API_KEY = process.env.ALPHA_VANTAGE_API_KEY || 'EZ4LLXN8DW0J4G7P'
interface StockQuote {
symbol: string
price: number
change: number
changePercent: number
volume: number
latestTradingDay: string
}
// 获取股票报价
export async function getStockQuote(symbol: string): Promise<StockQuote | null> {
try {
const url = `${ALPHA_VANTAGE_BASE_URL}?function=GLOBAL_QUOTE&symbol=${symbol}&apikey=${API_KEY}`
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
},
})
if (!response.ok) {
console.error(`Alpha Vantage API error: ${response.status}`)
return null
}
const data = await response.json()
if (data['Global Quote']) {
const q = data['Global Quote']
return {
symbol: q['01. symbol'],
price: parseFloat(q['05. price']),
change: parseFloat(q['09. change']),
changePercent: parseFloat(q['10. change percent']?.replace('%', '') || '0'),
volume: parseInt(q['06. volume']),
latestTradingDay: q['07. latest trading day'],
}
}
return null
} catch (error) {
console.error('Failed to fetch stock quote:', error)
return null
}
}
// 批量获取报价(带缓存)
const priceCache: Record<string, { price: number; timestamp: number }> = {}
const CACHE_DURATION = 60 * 1000 // 1分钟缓存
export async function getCachedPrice(symbol: string): Promise<number> {
const now = Date.now()
const cached = priceCache[symbol]
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.price
}
const quote = await getStockQuote(symbol)
if (quote) {
priceCache[symbol] = { price: quote.price, timestamp: now }
return quote.price
}
// 如果获取失败返回缓存价格或默认0
return cached?.price || 0
}
// 清除缓存
export function clearPriceCache() {
Object.keys(priceCache).forEach(key => delete priceCache[key])
}

114
src/types/index.ts Normal file
View File

@ -0,0 +1,114 @@
// 市场类型
export type MarketType = 'US' | 'CN' | 'HK' | 'CRYPTO'
// 交易类型
export type TransactionType =
| 'DEPOSIT' // 入金
| 'WITHDRAW' // 出金
| 'BUY' // 买入
| 'SELL' // 卖出
| 'DIVIDEND' // 分红
| 'INTEREST' // 利息
| 'FEE' // 费用
// 账户
export interface Account {
id: string
name: string
marketType: MarketType
baseCurrency: string
balance: string
createdAt: string
}
// 证券
export interface Security {
id: string
symbol: string
name: string
market: MarketType
currency: string
lotSize: number
priceDecimals: number
qtyDecimals: number
isCrypto: boolean
}
// 交易流水
export interface Transaction {
id: string
accountId: string
type: TransactionType
symbol?: string
quantity?: string
price?: string
amount: string
fee: string
networkFee?: string
currency: string
exchangeRate?: string
notes?: string
executedAt: string
createdAt: string
account?: { name: string; marketType: MarketType }
}
// 持仓
export interface Position {
id: string
accountId: string
symbol: string
quantity: string
averageCost: string
currency: string
currentPrice?: string
marketType?: MarketType
accountName?: string
name?: string
value?: number
valueInUSD?: number
pnl?: number
pnlPercent?: number
isCrypto?: boolean
}
// 仪表盘统计
export interface DashboardStats {
totalAssets: number
totalCash: number
totalPosition: number
totalPnL: number
byMarket: {
US: { cash: number; position: number; total: number }
CN: { cash: number; position: number; total: number }
HK: { cash: number; position: number; total: number }
CRYPTO: { cash: number; position: number; total: number }
}
positionDetails: PositionDetail[]
}
export interface PositionDetail {
symbol: string
name: string
quantity: number
avgCost: number
currentPrice: number
value: number
valueInUSD: number
pnl: number
pnlPercent: number
marketType: string
currency: string
}
// API 响应类型
export interface ApiResponse<T> {
data?: T
error?: string
pagination?: {
page: number
limit: number
total: number
totalPages: number
}
}