feat: initial commit
This commit is contained in:
parent
db014956c2
commit
344018063d
2
.gitignore
vendored
2
.gitignore
vendored
@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
25
components.json
Normal file
25
components.json
Normal 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
4180
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -9,9 +9,18 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.2.3",
|
||||||
"react": "19.2.4",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -20,7 +29,11 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "npx ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
prisma.config.ts
Normal file
16
prisma.config.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
128
prisma/migrations/20260411202803_init/migration.sql
Normal file
128
prisma/migrations/20260411202803_init/migration.sql
Normal 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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
117
prisma/schema.prisma
Normal 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) // 数量精度:股票=0,crypto=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
95
prisma/seed.ts
Normal 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()
|
||||||
|
})
|
||||||
45
src/app/api/accounts/route.ts
Normal file
45
src/app/api/accounts/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/app/api/dashboard/stats/route.ts
Normal file
126
src/app/api/dashboard/stats/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/api/exchange-rates/route.ts
Normal file
46
src/app/api/exchange-rates/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/positions/route.ts
Normal file
59
src/app/api/positions/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/api/securities/route.ts
Normal file
54
src/app/api/securities/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/app/api/transactions/route.ts
Normal file
136
src/app/api/transactions/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,26 +1,130 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--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 {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: oklch(1 0 0);
|
||||||
--foreground: #ededed;
|
--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 {
|
.dark {
|
||||||
background: var(--background);
|
--background: oklch(0.145 0 0);
|
||||||
color: var(--foreground);
|
--foreground: oklch(0.985 0 0);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
--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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal 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
9
src/lib/prisma.ts
Normal 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
6
src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user