feat: initial commit

This commit is contained in:
kennethcheng 2026-04-12 04:30:32 +08:00
parent db014956c2
commit 344018063d
19 changed files with 5138 additions and 118 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

4180
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,18 @@
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@prisma/client": "^6.19.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -20,7 +29,11 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"prisma": "^6.19.3",
"tailwindcss": "^4",
"typescript": "^5"
},
"prisma": {
"seed": "npx ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
}

16
prisma.config.ts Normal file
View File

@ -0,0 +1,16 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@ -0,0 +1,128 @@
-- CreateEnum
CREATE TYPE "MarketType" AS ENUM ('US', 'CN', 'HK', 'CRYPTO');
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('DEPOSIT', 'WITHDRAW', 'BUY', 'SELL', 'DIVIDEND', 'INTEREST', 'FEE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"marketType" "MarketType" NOT NULL,
"baseCurrency" TEXT NOT NULL,
"balance" DECIMAL(20,4) NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Security" (
"id" TEXT NOT NULL,
"symbol" TEXT NOT NULL,
"name" TEXT NOT NULL,
"market" "MarketType" NOT NULL,
"currency" TEXT NOT NULL,
"lotSize" INTEGER NOT NULL DEFAULT 1,
"priceDecimals" INTEGER NOT NULL DEFAULT 2,
"qtyDecimals" INTEGER NOT NULL DEFAULT 0,
"isCrypto" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Security_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Transaction" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"symbol" TEXT,
"quantity" DECIMAL(20,8),
"price" DECIMAL(20,8),
"amount" DECIMAL(20,4) NOT NULL,
"fee" DECIMAL(20,4) NOT NULL DEFAULT 0,
"networkFee" DECIMAL(20,8),
"currency" TEXT NOT NULL,
"exchangeRate" DECIMAL(20,8),
"notes" TEXT,
"executedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Position" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"symbol" TEXT NOT NULL,
"quantity" DECIMAL(20,8) NOT NULL,
"averageCost" DECIMAL(20,8) NOT NULL,
"currency" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Position_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExchangeRate" (
"id" TEXT NOT NULL,
"fromCurrency" TEXT NOT NULL,
"toCurrency" TEXT NOT NULL,
"rate" DECIMAL(20,8) NOT NULL,
"effectiveDate" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExchangeRate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "Account_userId_idx" ON "Account"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Security_symbol_key" ON "Security"("symbol");
-- CreateIndex
CREATE INDEX "Transaction_accountId_executedAt_idx" ON "Transaction"("accountId", "executedAt");
-- CreateIndex
CREATE INDEX "Transaction_symbol_idx" ON "Transaction"("symbol");
-- CreateIndex
CREATE INDEX "Position_accountId_idx" ON "Position"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "Position_accountId_symbol_key" ON "Position"("accountId", "symbol");
-- CreateIndex
CREATE INDEX "ExchangeRate_fromCurrency_toCurrency_idx" ON "ExchangeRate"("fromCurrency", "toCurrency");
-- CreateIndex
CREATE UNIQUE INDEX "ExchangeRate_fromCurrency_toCurrency_effectiveDate_key" ON "ExchangeRate"("fromCurrency", "toCurrency", "effectiveDate");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Position" ADD CONSTRAINT "Position_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

117
prisma/schema.prisma Normal file
View File

@ -0,0 +1,117 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 用户表(预留扩展)
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
}
// 账户表
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
name String
marketType MarketType
baseCurrency String
balance Decimal @default(0) @db.Decimal(20, 4)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
positions Position[]
@@index([userId])
}
enum MarketType {
US // 美股
CN // A股
HK // 港股
CRYPTO // 加密货币
}
// 证券参考表
model Security {
id String @id @default(cuid())
symbol String @unique
name String
market MarketType
currency String
lotSize Int @default(1) // 港股每手股数A股=100
priceDecimals Int @default(2) // 价格精度
qtyDecimals Int @default(0) // 数量精度:股票=0crypto=8
isCrypto Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 交易流水表
model Transaction {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id])
type TransactionType
symbol String?
quantity Decimal? @db.Decimal(20, 8)
price Decimal? @db.Decimal(20, 8)
amount Decimal @db.Decimal(20, 4)
fee Decimal @default(0) @db.Decimal(20, 4)
networkFee Decimal? @db.Decimal(20, 8)
currency String
exchangeRate Decimal? @db.Decimal(20, 8)
notes String?
executedAt DateTime
createdAt DateTime @default(now())
@@index([accountId, executedAt])
@@index([symbol])
}
enum TransactionType {
DEPOSIT // 入金
WITHDRAW // 出金
BUY // 买入
SELL // 卖出
DIVIDEND // 分红
INTEREST // 利息
FEE // 费用/手续费
}
// 持仓表(查询优化)
model Position {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id])
symbol String
quantity Decimal @db.Decimal(20, 8)
averageCost Decimal @db.Decimal(20, 8)
currency String
updatedAt DateTime @updatedAt
@@unique([accountId, symbol])
@@index([accountId])
}
// 汇率参考表
model ExchangeRate {
id String @id @default(cuid())
fromCurrency String
toCurrency String
rate Decimal @db.Decimal(20, 8)
effectiveDate DateTime
createdAt DateTime @default(now())
@@unique([fromCurrency, toCurrency, effectiveDate])
@@index([fromCurrency, toCurrency])
}

95
prisma/seed.ts Normal file
View File

@ -0,0 +1,95 @@
import { PrismaClient, MarketType } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// 创建默认用户
const user = await prisma.user.upsert({
where: { email: 'demo@portfolio.local' },
update: {},
create: {
email: 'demo@portfolio.local',
name: 'Demo User',
},
})
// 创建各市场的账户
const accounts = [
{ name: '富途港股', marketType: MarketType.HK, baseCurrency: 'HKD' },
{ name: '老虎美股', marketType: MarketType.US, baseCurrency: 'USD' },
{ name: 'A股通', marketType: MarketType.CN, baseCurrency: 'CNY' },
{ name: '币安', marketType: MarketType.CRYPTO, baseCurrency: 'USDT' },
]
for (const acc of accounts) {
await prisma.account.upsert({
where: { id: `${user.id}-${acc.marketType}` },
update: {},
create: {
id: `${user.id}-${acc.marketType}`,
userId: user.id,
name: acc.name,
marketType: acc.marketType,
baseCurrency: acc.baseCurrency,
balance: 0,
},
})
}
// 初始化汇率(以 USD 为基准)
const exchangeRates = [
{ fromCurrency: 'USD', toCurrency: 'USD', rate: 1, date: new Date('2026-01-01') },
{ fromCurrency: 'CNY', toCurrency: 'USD', rate: 0.137, date: new Date('2026-01-01') },
{ fromCurrency: 'HKD', toCurrency: 'USD', rate: 0.129, date: new Date('2026-01-01') },
{ fromCurrency: 'USDT', toCurrency: 'USD', rate: 1, date: new Date('2026-01-01') },
]
for (const rate of exchangeRates) {
await prisma.exchangeRate.upsert({
where: {
fromCurrency_toCurrency_effectiveDate: {
fromCurrency: rate.fromCurrency,
toCurrency: rate.toCurrency,
effectiveDate: rate.date,
},
},
update: {},
create: {
fromCurrency: rate.fromCurrency,
toCurrency: rate.toCurrency,
rate: rate.rate,
effectiveDate: rate.date,
},
})
}
// 初始化常见证券
const securities = [
{ symbol: '00700', name: '腾讯控股', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: '09988', name: '阿里巴巴', market: MarketType.HK, currency: 'HKD', lotSize: 100, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'AAPL', name: 'Apple Inc.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'MSFT', name: 'Microsoft Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'NVDA', name: 'NVIDIA Corp.', market: MarketType.US, currency: 'USD', lotSize: 1, priceDecimals: 2, qtyDecimals: 0 },
{ symbol: 'BTC', name: 'Bitcoin', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true },
{ symbol: 'ETH', name: 'Ethereum', market: MarketType.CRYPTO, currency: 'USDT', lotSize: 1, priceDecimals: 2, qtyDecimals: 8, isCrypto: true },
]
for (const sec of securities) {
await prisma.security.upsert({
where: { symbol: sec.symbol },
update: {},
create: sec,
})
}
console.log('Seed completed:', { user, accountsCreated: accounts.length })
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// 获取账户列表
export async function GET() {
try {
const accounts = await prisma.account.findMany({
where: { user: { email: 'demo@portfolio.local' } },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json(accounts)
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch accounts' }, { status: 500 })
}
}
// 创建账户
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, marketType, baseCurrency } = body
const user = await prisma.user.findFirst({
where: { email: 'demo@portfolio.local' },
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const account = await prisma.account.create({
data: {
userId: user.id,
name,
marketType,
baseCurrency,
balance: 0,
},
})
return NextResponse.json(account, { status: 201 })
} catch (error) {
return NextResponse.json({ error: 'Failed to create account' }, { status: 500 })
}
}

View File

@ -0,0 +1,126 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// 仪表盘统计数据
export async function GET() {
try {
// 1. 获取所有账户
const accounts = await prisma.account.findMany({
where: { user: { email: 'demo@portfolio.local' } },
})
// 2. 获取最新汇率
const latestRates = await prisma.exchangeRate.findMany({
orderBy: { effectiveDate: 'desc' },
})
const rateMap = new Map(
latestRates.map((r) => [`${r.fromCurrency}_${r.toCurrency}`, Number(r.rate)])
)
// 3. 计算各账户现金折算为 USD
const cashInUSD = accounts.reduce((sum, acc) => {
const rate = rateMap.get(`${acc.baseCurrency}_USD`) || 1
const balance = Number(acc.balance)
return sum + balance * rate
}, 0)
// 4. 获取所有持仓
const positions = await prisma.position.findMany({
where: { account: { user: { email: 'demo@portfolio.local' } } },
include: { account: true },
})
// 5. 获取证券信息
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]))
// 6. 计算持仓市值(使用平均成本模拟当前价格)
let totalPositionValue = 0
const positionDetails: Record<string, {
symbol: string
name: string
quantity: number
avgCost: number
currentPrice: number
value: number
valueInUSD: number
pnl: number
pnlPercent: number
marketType: string
currency: string
}> = {}
for (const pos of positions) {
const security = securityMap.get(pos.symbol)
const qty = Number(pos.quantity)
const avgCost = Number(pos.averageCost)
const currentPrice = avgCost // 模拟:使用成本价,实际应获取实时价格
const value = qty * currentPrice
const costBasis = qty * avgCost
const pnl = value - costBasis
const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0
const rate = rateMap.get(`${pos.currency}_USD`) || 1
totalPositionValue += value * rate
positionDetails[pos.symbol] = {
symbol: pos.symbol,
name: security?.name || pos.symbol,
quantity: qty,
avgCost,
currentPrice,
value,
valueInUSD: value * rate,
pnl,
pnlPercent,
marketType: pos.account.marketType,
currency: pos.currency,
}
}
// 7. 按市场类型分组
const byMarket = {
US: { cash: 0, position: 0, total: 0 },
CN: { cash: 0, position: 0, total: 0 },
HK: { cash: 0, position: 0, total: 0 },
CRYPTO: { cash: 0, position: 0, total: 0 },
}
for (const acc of accounts) {
const rate = rateMap.get(`${acc.baseCurrency}_USD`) || 1
const cashUSD = Number(acc.balance) * rate
byMarket[acc.marketType].cash = cashUSD
}
for (const pos of positions) {
const rate = rateMap.get(`${pos.currency}_USD`) || 1
const valueUSD = Number(pos.quantity) * Number(pos.averageCost) * rate
byMarket[pos.account.marketType].position += valueUSD
}
for (const market of Object.keys(byMarket)) {
byMarket[market as keyof typeof byMarket].total =
byMarket[market as keyof typeof byMarket].cash +
byMarket[market as keyof typeof byMarket].position
}
// 8. 计算总资产
const totalAssets = cashInUSD + totalPositionValue
return NextResponse.json({
totalAssets,
totalCash: cashInUSD,
totalPosition: totalPositionValue,
totalPnL: totalPositionValue - (positions.reduce((sum, p) => sum + Number(p.quantity) * Number(p.averageCost), 0)),
byMarket,
positionDetails: Object.values(positionDetails),
})
} catch (error) {
console.error('Dashboard error:', error)
return NextResponse.json({ error: 'Failed to fetch dashboard stats' }, { status: 500 })
}
}

View File

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// 获取汇率列表
export async function GET() {
try {
const rates = await prisma.exchangeRate.findMany({
orderBy: { effectiveDate: 'desc' },
take: 10,
})
// 按币种分组,只取最新
const latestRates: Record<string, { rate: number; date: Date }> = {}
for (const r of rates) {
const key = `${r.fromCurrency}_${r.toCurrency}`
if (!latestRates[key]) {
latestRates[key] = { rate: Number(r.rate), date: r.effectiveDate }
}
}
return NextResponse.json(latestRates)
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch exchange rates' }, { status: 500 })
}
}
// 更新汇率
export async function POST(request: Request) {
try {
const body = await request.json()
const { fromCurrency, toCurrency, rate, effectiveDate } = body
const exchangeRate = await prisma.exchangeRate.create({
data: {
fromCurrency,
toCurrency,
rate,
effectiveDate: new Date(effectiveDate),
},
})
return NextResponse.json(exchangeRate, { status: 201 })
} catch (error) {
return NextResponse.json({ error: 'Failed to update exchange rate' }, { status: 500 })
}
}

View File

@ -0,0 +1,59 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// 获取持仓列表(包含市值计算)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const accountId = searchParams.get('accountId')
const where = accountId ? { accountId } : {}
const positions = await prisma.position.findMany({
where,
include: {
account: { select: { name: true, marketType: true, baseCurrency: true } },
},
})
// 获取最新汇率
const latestRates = await prisma.exchangeRate.findMany({
where: {
effectiveDate: {
gte: new Date(new Date().setHours(0, 0, 0, 0)),
},
},
orderBy: { effectiveDate: 'desc' },
})
// 获取证券价格信息
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]))
// 转换持仓数据附加市值信息模拟价格实际应从实时API获取
const positionsWithMarket = positions.map((pos) => {
const security = securityMap.get(pos.symbol)
const marketPrice = security?.priceDecimals ?? 2
return {
...pos,
quantity: pos.quantity.toString(),
averageCost: pos.averageCost.toString(),
// 这里应该用实时价格,暂时用成本价模拟
currentPrice: pos.averageCost.toString(),
currency: pos.currency,
marketType: pos.account.marketType,
accountName: pos.account.name,
lotSize: security?.lotSize ?? 1,
isCrypto: security?.isCrypto ?? false,
}
})
return NextResponse.json(positionsWithMarket)
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch positions' }, { status: 500 })
}
}

View File

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// 获取证券列表
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const market = searchParams.get('market')
const search = searchParams.get('search')
const where: Record<string, unknown> = {}
if (market) where.market = market
if (search) {
where.OR = [
{ symbol: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
]
}
const securities = await prisma.security.findMany({
where,
orderBy: [{ market: 'asc' }, { symbol: 'asc' }],
})
return NextResponse.json(securities)
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch securities' }, { status: 500 })
}
}
// 添加证券
export async function POST(request: Request) {
try {
const body = await request.json()
const { symbol, name, market, currency, lotSize = 1, priceDecimals = 2, qtyDecimals = 0, isCrypto = false } = body
const security = await prisma.security.create({
data: {
symbol: symbol.toUpperCase(),
name,
market,
currency,
lotSize,
priceDecimals,
qtyDecimals,
isCrypto,
},
})
return NextResponse.json(security, { status: 201 })
} catch (error) {
return NextResponse.json({ error: 'Failed to create security' }, { status: 500 })
}
}

View File

@ -0,0 +1,136 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
// 获取交易流水
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const accountId = searchParams.get('accountId')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const where = accountId ? { accountId } : {}
const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
include: { account: { select: { name: true, marketType: true } } },
orderBy: { executedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.transaction.count({ where }),
])
return NextResponse.json({
data: transactions,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
})
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
}
// 记录交易(包含持仓更新逻辑)
export async function POST(request: Request) {
try {
const body = await request.json()
const {
accountId,
type,
symbol,
quantity,
price,
amount,
fee = 0,
networkFee,
currency,
exchangeRate,
notes,
executedAt,
} = body
// 开启事务
const result = await prisma.$transaction(async (tx) => {
// 1. 创建交易记录
const transaction = await tx.transaction.create({
data: {
accountId,
type,
symbol: symbol || null,
quantity: quantity ? new Prisma.Decimal(quantity) : null,
price: price ? new Prisma.Decimal(price) : null,
amount: new Prisma.Decimal(amount),
fee: new Prisma.Decimal(fee),
networkFee: networkFee ? new Prisma.Decimal(networkFee) : null,
currency,
exchangeRate: exchangeRate ? new Prisma.Decimal(exchangeRate) : null,
notes,
executedAt: new Date(executedAt),
},
})
// 2. 更新账户余额(出金入金)
if (type === 'DEPOSIT' || type === 'WITHDRAW') {
const balanceChange = type === 'DEPOSIT'
? new Prisma.Decimal(amount)
: new Prisma.Decimal(amount).negated()
await tx.account.update({
where: { id: accountId },
data: { balance: { increment: balanceChange } },
})
}
// 3. 更新持仓(买入/卖出/分红)
if ((type === 'BUY' || type === 'SELL' || type === 'DIVIDEND') && symbol) {
const position = await tx.position.findUnique({
where: { accountId_symbol: { accountId, symbol } },
})
if (type === 'BUY' && quantity && price) {
// 买入:更新持仓数量和平均成本
const buyAmount = new Prisma.Decimal(quantity).times(new Prisma.Decimal(price))
if (position) {
const newQty = position.quantity.plus(new Prisma.Decimal(quantity))
const newCost = position.averageCost.times(position.quantity).plus(buyAmount).div(newQty)
await tx.position.update({
where: { accountId_symbol: { accountId, symbol } },
data: {
quantity: newQty,
averageCost: newCost,
},
})
} else {
await tx.position.create({
data: {
accountId,
symbol,
quantity: new Prisma.Decimal(quantity),
averageCost: new Prisma.Decimal(price),
currency,
},
})
}
} else if (type === 'SELL' && quantity && price) {
// 卖出:减少持仓(平均成本法)
if (position) {
const newQty = position.quantity.minus(new Prisma.Decimal(quantity))
await tx.position.update({
where: { accountId_symbol: { accountId, symbol } },
data: { quantity: newQty },
})
}
}
// DIVIDEND目前只记录流水持仓不变
}
return transaction
})
return NextResponse.json(result, { status: 201 })
} catch (error) {
console.error('Transaction error:', error)
return NextResponse.json({ error: 'Failed to create transaction' }, { status: 500 })
}
}

View File

@ -1,26 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

9
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}