提交
This commit is contained in:
parent
344018063d
commit
f6436db200
@ -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
22
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
164
src/app/api/dashboard/analytics/route.ts
Normal file
164
src/app/api/dashboard/analytics/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/app/api/import/transactions/route.ts
Normal file
110
src/app/api/import/transactions/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
1096
src/app/page.tsx
1096
src/app/page.tsx
File diff suppressed because it is too large
Load Diff
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal 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
103
src/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
160
src/components/ui/dialog.tsx
Normal file
160
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
268
src/components/ui/dropdown-menu.tsx
Normal file
268
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal 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 }
|
||||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal 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 }
|
||||||
201
src/components/ui/select.tsx
Normal file
201
src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal 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 }
|
||||||
49
src/components/ui/sonner.tsx
Normal file
49
src/components/ui/sonner.tsx
Normal 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
116
src/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
82
src/components/ui/tabs.tsx
Normal file
82
src/components/ui/tabs.tsx
Normal 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
151
src/lib/api.ts
Normal 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
193
src/lib/import-export.ts
Normal 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
76
src/lib/price-service.ts
Normal 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
114
src/types/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user