add credit page
This commit is contained in:
parent
13d4027559
commit
8282039b1d
1
.gitignore
vendored
1
.gitignore
vendored
@ -41,3 +41,4 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
.playwright-mcp
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
"plaza": "Plaza",
|
"plaza": "Plaza",
|
||||||
"pricing": "Pricing",
|
"pricing": "Pricing",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
|
"credits": "Credits",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
@ -105,7 +106,40 @@
|
|||||||
"proPlan": "Pro Plan",
|
"proPlan": "Pro Plan",
|
||||||
"usdCredit": "USD",
|
"usdCredit": "USD",
|
||||||
"subscriptionInfo": "Subscription Info",
|
"subscriptionInfo": "Subscription Info",
|
||||||
"currentPlan": "Current Plan"
|
"currentPlan": "Current Plan",
|
||||||
|
"viewTransactionHistory": "View Transaction History"
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"title": "Credit Transaction Log",
|
||||||
|
"subtitle": "Track your credit purchases and usage history",
|
||||||
|
"currentBalance": "Current Balance",
|
||||||
|
"totalEarned": "Total Earned",
|
||||||
|
"totalSpent": "Total Spent",
|
||||||
|
"thisMonthEarned": "This Month Earned",
|
||||||
|
"thisMonthSpent": "This Month Spent",
|
||||||
|
"transactionHistory": "Transaction History",
|
||||||
|
"systemGift": "System Gift",
|
||||||
|
"monthlyAllowance": "Monthly Allowance",
|
||||||
|
"purchase": "Purchase",
|
||||||
|
"usage": "Usage",
|
||||||
|
"simulation": "Simulation",
|
||||||
|
"apiCall": "API Call",
|
||||||
|
"export": "Export",
|
||||||
|
"filters": "Filters",
|
||||||
|
"allTypes": "All Types",
|
||||||
|
"allCategories": "All Categories",
|
||||||
|
"newestFirst": "Newest First",
|
||||||
|
"oldestFirst": "Oldest First",
|
||||||
|
"highestAmount": "Highest Amount",
|
||||||
|
"lowestAmount": "Lowest Amount",
|
||||||
|
"highestBalance": "Highest Balance",
|
||||||
|
"lowestBalance": "Lowest Balance",
|
||||||
|
"noTransactions": "No transactions found",
|
||||||
|
"noDescription": "No description",
|
||||||
|
"balance": "Balance",
|
||||||
|
"showing": "Showing",
|
||||||
|
"of": "of",
|
||||||
|
"transactions": "transactions"
|
||||||
},
|
},
|
||||||
"studio": {
|
"studio": {
|
||||||
"title": "AI Prompt Studio",
|
"title": "AI Prompt Studio",
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
"plaza": "广场",
|
"plaza": "广场",
|
||||||
"pricing": "价格",
|
"pricing": "价格",
|
||||||
"subscription": "订阅",
|
"subscription": "订阅",
|
||||||
|
"credits": "信用记录",
|
||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
"admin": "管理员后台",
|
"admin": "管理员后台",
|
||||||
"signIn": "登录",
|
"signIn": "登录",
|
||||||
@ -105,7 +106,40 @@
|
|||||||
"proPlan": "专业版",
|
"proPlan": "专业版",
|
||||||
"usdCredit": "美元",
|
"usdCredit": "美元",
|
||||||
"subscriptionInfo": "订阅信息",
|
"subscriptionInfo": "订阅信息",
|
||||||
"currentPlan": "当前方案"
|
"currentPlan": "当前方案",
|
||||||
|
"viewTransactionHistory": "查看交易记录"
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"title": "信用交易记录",
|
||||||
|
"subtitle": "跟踪您的信用购买和使用记录",
|
||||||
|
"currentBalance": "当前余额",
|
||||||
|
"totalEarned": "总收入",
|
||||||
|
"totalSpent": "总支出",
|
||||||
|
"thisMonthEarned": "本月收入",
|
||||||
|
"thisMonthSpent": "本月支出",
|
||||||
|
"transactionHistory": "交易记录",
|
||||||
|
"systemGift": "系统赠送",
|
||||||
|
"monthlyAllowance": "月度额度",
|
||||||
|
"purchase": "购买充值",
|
||||||
|
"usage": "消费使用",
|
||||||
|
"simulation": "模拟器",
|
||||||
|
"apiCall": "API调用",
|
||||||
|
"export": "导出功能",
|
||||||
|
"filters": "筛选条件",
|
||||||
|
"allTypes": "全部类型",
|
||||||
|
"allCategories": "全部分类",
|
||||||
|
"newestFirst": "最新优先",
|
||||||
|
"oldestFirst": "最旧优先",
|
||||||
|
"highestAmount": "金额最高",
|
||||||
|
"lowestAmount": "金额最低",
|
||||||
|
"highestBalance": "余额最高",
|
||||||
|
"lowestBalance": "余额最低",
|
||||||
|
"noTransactions": "暂无交易记录",
|
||||||
|
"noDescription": "无描述",
|
||||||
|
"balance": "余额",
|
||||||
|
"showing": "显示",
|
||||||
|
"of": "的",
|
||||||
|
"transactions": "条记录"
|
||||||
},
|
},
|
||||||
"studio": {
|
"studio": {
|
||||||
"title": "AI 提示词工作室",
|
"title": "AI 提示词工作室",
|
||||||
|
@ -165,15 +165,19 @@ model PromptStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Credit {
|
model Credit {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
amount Float // 信用额度数量
|
amount Float // 交易金额(正数为充值,负数为消费)
|
||||||
type String // "system_gift", "subscription_monthly", "user_purchase"
|
balance Float // 执行该交易后的余额
|
||||||
note String? // 备注说明
|
type String // "system_gift", "subscription_monthly", "user_purchase", "consumption"
|
||||||
expiresAt DateTime? // 过期时间,null表示永久有效
|
category String? // 消费类别: "simulation", "api_call", "export" 等
|
||||||
isActive Boolean @default(true) // 是否激活状态
|
note String? // 备注说明
|
||||||
createdAt DateTime @default(now())
|
referenceId String? // 关联的记录ID(如SimulatorRun的ID)
|
||||||
updatedAt DateTime @updatedAt
|
referenceType String? // 关联记录类型(如"simulator_run")
|
||||||
|
expiresAt DateTime? // 过期时间,null表示永久有效
|
||||||
|
isActive Boolean @default(true) // 是否激活状态
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@ -266,6 +270,7 @@ model SimulatorRun {
|
|||||||
outputTokens Int? // 输出token数
|
outputTokens Int? // 输出token数
|
||||||
totalCost Float? // 总消费
|
totalCost Float? // 总消费
|
||||||
duration Int? // 运行时长(毫秒)
|
duration Int? // 运行时长(毫秒)
|
||||||
|
creditId String? // 关联的信用消费记录ID
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
49
scripts/seed-credits.ts
Normal file
49
scripts/seed-credits.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { prisma } from '../src/lib/prisma'
|
||||||
|
import { addCredit } from '../src/lib/services/credit'
|
||||||
|
|
||||||
|
async function seedCredits() {
|
||||||
|
try {
|
||||||
|
// Find the first user to add sample credits
|
||||||
|
const user = await prisma.user.findFirst()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('No users found. Please create a user first.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Adding sample credits for user: ${user.email}`)
|
||||||
|
|
||||||
|
// Add system gift
|
||||||
|
await addCredit(
|
||||||
|
user.id,
|
||||||
|
5.0,
|
||||||
|
'system_gift',
|
||||||
|
'Welcome bonus - Free credits for new users!'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add monthly allowance
|
||||||
|
await addCredit(
|
||||||
|
user.id,
|
||||||
|
20.0,
|
||||||
|
'subscription_monthly',
|
||||||
|
'Pro plan monthly allowance'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add user purchase
|
||||||
|
await addCredit(
|
||||||
|
user.id,
|
||||||
|
10.0,
|
||||||
|
'user_purchase',
|
||||||
|
'Top-up purchase via Stripe'
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('✅ Sample credits added successfully!')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error seeding credits:', error)
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedCredits()
|
27
src/app/api/credits/stats/route.ts
Normal file
27
src/app/api/credits/stats/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||||
|
import { getCreditStats } from '@/lib/services/credit'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const supabase = await createServerSupabaseClient()
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await getCreditStats(user.id)
|
||||||
|
|
||||||
|
return NextResponse.json(stats)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching credit stats:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
53
src/app/api/credits/transactions/route.ts
Normal file
53
src/app/api/credits/transactions/route.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||||
|
import { getCreditTransactions } from '@/lib/services/credit'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const supabase = await createServerSupabaseClient()
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '20', 10)
|
||||||
|
const type = searchParams.get('type') || undefined
|
||||||
|
const category = searchParams.get('category') || undefined
|
||||||
|
const sortBy = (searchParams.get('sortBy') || 'createdAt') as 'createdAt' | 'amount' | 'balance'
|
||||||
|
const sortOrder = (searchParams.get('sortOrder') || 'desc') as 'asc' | 'desc'
|
||||||
|
|
||||||
|
// Parse date filters if provided
|
||||||
|
const startDateParam = searchParams.get('startDate')
|
||||||
|
const endDateParam = searchParams.get('endDate')
|
||||||
|
const startDate = startDateParam ? new Date(startDateParam) : undefined
|
||||||
|
const endDate = endDateParam ? new Date(endDateParam) : undefined
|
||||||
|
|
||||||
|
const result = await getCreditTransactions({
|
||||||
|
userId: user.id,
|
||||||
|
page,
|
||||||
|
limit: Math.min(limit, 100), // Cap at 100 to prevent excessive load
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching credit transactions:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { createServerSupabaseClient } from "@/lib/supabase-server";
|
import { createServerSupabaseClient } from "@/lib/supabase-server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getPromptContent, calculateCost } from "@/lib/simulator-utils";
|
import { getPromptContent, calculateCost } from "@/lib/simulator-utils";
|
||||||
|
import { consumeCreditForSimulation, getUserBalance } from "@/lib/services/credit";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@ -36,6 +37,17 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "Run already executed" }, { status: 400 });
|
return NextResponse.json({ error: "Run already executed" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check user's credit balance before execution
|
||||||
|
const userBalance = await getUserBalance(user.id);
|
||||||
|
const estimatedCost = calculateCost(0, 100, run.model); // Rough estimate
|
||||||
|
|
||||||
|
if (userBalance < estimatedCost) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Insufficient credit balance", requiredCredit: estimatedCost, currentBalance: userBalance },
|
||||||
|
{ status: 402 } // Payment Required
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 更新状态为运行中
|
// 更新状态为运行中
|
||||||
await prisma.simulatorRun.update({
|
await prisma.simulatorRun.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@ -115,6 +127,30 @@ export async function POST(
|
|||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const actualCost = calculateCost(inputTokens, outputTokens, run.model);
|
const actualCost = calculateCost(inputTokens, outputTokens, run.model);
|
||||||
|
|
||||||
|
// Consume credits for this simulation
|
||||||
|
let creditTransaction;
|
||||||
|
try {
|
||||||
|
creditTransaction = await consumeCreditForSimulation(
|
||||||
|
user.id,
|
||||||
|
actualCost,
|
||||||
|
id,
|
||||||
|
`${run.model.name} simulation: ${inputTokens} input + ${outputTokens} output tokens`
|
||||||
|
);
|
||||||
|
} catch (creditError) {
|
||||||
|
// If credit consumption fails, mark the run as failed
|
||||||
|
await prisma.simulatorRun.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: "failed",
|
||||||
|
error: `Credit consumption failed: ${creditError}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
controller.enqueue(new TextEncoder().encode(`data: {"error": "Credit consumption failed"}\n\n`));
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the run with completion data and credit reference
|
||||||
await prisma.simulatorRun.update({
|
await prisma.simulatorRun.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@ -124,6 +160,7 @@ export async function POST(
|
|||||||
outputTokens,
|
outputTokens,
|
||||||
totalCost: actualCost,
|
totalCost: actualCost,
|
||||||
duration,
|
duration,
|
||||||
|
creditId: creditTransaction.id,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,23 +1,71 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getUserCreditSummary, checkAndRefreshProMonthlyCredit } from '@/lib/credits'
|
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||||
|
import { getUserBalance } from '@/lib/services/credit'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/users/credits - 获取用户信用额度概览
|
// GET /api/users/credits - 获取用户信用额度概览(向后兼容)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const supabase = await createServerSupabaseClient()
|
||||||
const userId = searchParams.get('userId')
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
|
|
||||||
if (!userId) {
|
if (authError || !user) {
|
||||||
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查Pro用户是否需要刷新月度额度
|
const { searchParams } = new URL(request.url)
|
||||||
await checkAndRefreshProMonthlyCredit(userId)
|
const userId = searchParams.get('userId') || user.id
|
||||||
|
|
||||||
// 获取信用额度概览
|
if (userId !== user.id) {
|
||||||
const creditSummary = await getUserCreditSummary(userId)
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(creditSummary)
|
// 获取当前余额
|
||||||
|
const totalBalance = await getUserBalance(userId)
|
||||||
|
|
||||||
|
// 获取活跃和过期的信用记录(为了向后兼容)
|
||||||
|
const activeCredits = await prisma.credit.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isActive: true,
|
||||||
|
type: { not: 'consumption' },
|
||||||
|
OR: [
|
||||||
|
{ expiresAt: null },
|
||||||
|
{ expiresAt: { gt: new Date() } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const expiredCredits = await prisma.credit.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isActive: true,
|
||||||
|
type: { not: 'consumption' },
|
||||||
|
expiresAt: { lte: new Date() }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalBalance,
|
||||||
|
activeCredits: activeCredits.map(credit => ({
|
||||||
|
id: credit.id,
|
||||||
|
amount: credit.amount,
|
||||||
|
type: credit.type,
|
||||||
|
note: credit.note,
|
||||||
|
expiresAt: credit.expiresAt,
|
||||||
|
createdAt: credit.createdAt
|
||||||
|
})),
|
||||||
|
expiredCredits: expiredCredits.map(credit => ({
|
||||||
|
id: credit.id,
|
||||||
|
amount: credit.amount,
|
||||||
|
type: credit.type,
|
||||||
|
note: credit.note,
|
||||||
|
expiresAt: credit.expiresAt,
|
||||||
|
createdAt: credit.createdAt
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching user credits:', error)
|
console.error('Error fetching user credits:', error)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
import { createServerSupabaseClient } from '@/lib/supabase-server'
|
||||||
import { addSystemGiftCredit } from '@/lib/credits'
|
import { addCredit } from '@/lib/services/credit'
|
||||||
|
|
||||||
// POST /api/users/sync - 同步Supabase用户到Prisma数据库
|
// POST /api/users/sync - 同步Supabase用户到Prisma数据库
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
@ -50,7 +50,16 @@ export async function POST() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 为新用户添加系统赠送的5USD信用额度(1个月后过期)
|
// 为新用户添加系统赠送的5USD信用额度(1个月后过期)
|
||||||
await addSystemGiftCredit(newUser.id)
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setMonth(expiresAt.getMonth() + 1)
|
||||||
|
|
||||||
|
await addCredit(
|
||||||
|
newUser.id,
|
||||||
|
5.0,
|
||||||
|
'system_gift',
|
||||||
|
'系统赠送 - 新用户礼包',
|
||||||
|
expiresAt
|
||||||
|
)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'User created successfully',
|
message: 'User created successfully',
|
||||||
|
438
src/app/credits/page.tsx
Normal file
438
src/app/credits/page.tsx
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { LoadingSpinner } from '@/components/ui/loading-spinner'
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Calendar,
|
||||||
|
Filter,
|
||||||
|
ArrowUpDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Zap
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface CreditTransaction {
|
||||||
|
id: string
|
||||||
|
amount: number
|
||||||
|
balance: number
|
||||||
|
type: string
|
||||||
|
category?: string
|
||||||
|
note?: string
|
||||||
|
referenceId?: string
|
||||||
|
referenceType?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditStats {
|
||||||
|
currentBalance: number
|
||||||
|
totalEarned: number
|
||||||
|
totalSpent: number
|
||||||
|
thisMonthEarned: number
|
||||||
|
thisMonthSpent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditTransactionsResult {
|
||||||
|
transactions: CreditTransaction[]
|
||||||
|
total: number
|
||||||
|
currentBalance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreditsPage() {
|
||||||
|
const { user, loading } = useAuthUser()
|
||||||
|
const t = useTranslations('credits')
|
||||||
|
|
||||||
|
const [transactions, setTransactions] = useState<CreditTransaction[]>([])
|
||||||
|
const [stats, setStats] = useState<CreditStats | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
// Filters and pagination
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [pageSize] = useState(20)
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||||
|
const [sortBy, setSortBy] = useState<'createdAt' | 'amount' | 'balance'>('createdAt')
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize)
|
||||||
|
|
||||||
|
const loadCreditData = useCallback(async () => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.toString(),
|
||||||
|
limit: pageSize.toString(),
|
||||||
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeFilter) params.append('type', typeFilter)
|
||||||
|
if (categoryFilter) params.append('category', categoryFilter)
|
||||||
|
|
||||||
|
const [transactionsResponse, statsResponse] = await Promise.all([
|
||||||
|
fetch(`/api/credits/transactions?${params}`),
|
||||||
|
fetch('/api/credits/stats')
|
||||||
|
])
|
||||||
|
|
||||||
|
if (transactionsResponse.ok) {
|
||||||
|
const data: CreditTransactionsResult = await transactionsResponse.json()
|
||||||
|
setTransactions(data.transactions)
|
||||||
|
setTotal(data.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsResponse.ok) {
|
||||||
|
const statsData: CreditStats = await statsResponse.json()
|
||||||
|
setStats(statsData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading credit data:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [user, currentPage, pageSize, typeFilter, categoryFilter, sortBy, sortOrder])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
loadCreditData()
|
||||||
|
}
|
||||||
|
}, [user, loadCreditData])
|
||||||
|
|
||||||
|
const getTransactionIcon = (type: string, amount: number) => {
|
||||||
|
if (amount > 0) {
|
||||||
|
switch (type) {
|
||||||
|
case 'system_gift':
|
||||||
|
return <Zap className="w-4 h-4 text-blue-500" />
|
||||||
|
case 'subscription_monthly':
|
||||||
|
return <Calendar className="w-4 h-4 text-green-500" />
|
||||||
|
case 'user_purchase':
|
||||||
|
return <Plus className="w-4 h-4 text-green-500" />
|
||||||
|
default:
|
||||||
|
return <Plus className="w-4 h-4 text-green-500" />
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return <Minus className="w-4 h-4 text-red-500" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransactionColor = (amount: number) => {
|
||||||
|
return amount > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransactionBadgeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'system_gift':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||||
|
case 'subscription_monthly':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
|
case 'user_purchase':
|
||||||
|
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
|
||||||
|
case 'consumption':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTransactionType = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'system_gift':
|
||||||
|
return t('systemGift')
|
||||||
|
case 'subscription_monthly':
|
||||||
|
return t('monthlyAllowance')
|
||||||
|
case 'user_purchase':
|
||||||
|
return t('purchase')
|
||||||
|
case 'consumption':
|
||||||
|
return t('usage')
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground mb-2">{t('title')}</h1>
|
||||||
|
<p className="text-muted-foreground">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
<Card className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200/50 dark:border-green-800/50">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-green-500">
|
||||||
|
<CreditCard className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-700 dark:text-green-300">{t('currentBalance')}</p>
|
||||||
|
<p className="text-2xl font-bold text-green-800 dark:text-green-200">
|
||||||
|
${stats.currentBalance.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-blue-500">
|
||||||
|
<TrendingUp className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t('totalEarned')}</p>
|
||||||
|
<p className="text-xl font-bold text-foreground">${stats.totalEarned.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-red-500">
|
||||||
|
<TrendingDown className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t('totalSpent')}</p>
|
||||||
|
<p className="text-xl font-bold text-foreground">${stats.totalSpent.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-emerald-500">
|
||||||
|
<Calendar className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t('thisMonthEarned')}</p>
|
||||||
|
<p className="text-xl font-bold text-foreground">${stats.thisMonthEarned.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-orange-500">
|
||||||
|
<Calendar className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t('thisMonthSpent')}</p>
|
||||||
|
<p className="text-xl font-bold text-foreground">${stats.thisMonthSpent.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-foreground">{t('filters')}:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTypeFilter(e.target.value)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 border border-border rounded-md bg-background text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t('allTypes')}</option>
|
||||||
|
<option value="system_gift">{t('systemGift')}</option>
|
||||||
|
<option value="subscription_monthly">{t('monthlyAllowance')}</option>
|
||||||
|
<option value="user_purchase">{t('purchase')}</option>
|
||||||
|
<option value="consumption">{t('usage')}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCategoryFilter(e.target.value)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 border border-border rounded-md bg-background text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t('allCategories')}</option>
|
||||||
|
<option value="simulation">{t('simulation')}</option>
|
||||||
|
<option value="api_call">{t('apiCall')}</option>
|
||||||
|
<option value="export">{t('export')}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<ArrowUpDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
value={`${sortBy}-${sortOrder}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [field, order] = e.target.value.split('-')
|
||||||
|
setSortBy(field as 'createdAt' | 'amount' | 'balance')
|
||||||
|
setSortOrder(order as 'asc' | 'desc')
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 border border-border rounded-md bg-background text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="createdAt-desc">{t('newestFirst')}</option>
|
||||||
|
<option value="createdAt-asc">{t('oldestFirst')}</option>
|
||||||
|
<option value="amount-desc">{t('highestAmount')}</option>
|
||||||
|
<option value="amount-asc">{t('lowestAmount')}</option>
|
||||||
|
<option value="balance-desc">{t('highestBalance')}</option>
|
||||||
|
<option value="balance-asc">{t('lowestBalance')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transactions List */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">{t('transactionHistory')}</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
) : transactions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<CreditCard className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>{t('noTransactions')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{transactions.map((transaction) => (
|
||||||
|
<div
|
||||||
|
key={transaction.id}
|
||||||
|
className="flex items-center justify-between p-4 rounded-lg border border-border hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 rounded-full bg-muted">
|
||||||
|
{getTransactionIcon(transaction.type, transaction.amount)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<Badge className={`text-xs ${getTransactionBadgeColor(transaction.type)}`}>
|
||||||
|
{formatTransactionType(transaction.type)}
|
||||||
|
</Badge>
|
||||||
|
{transaction.category && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{transaction.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-foreground font-medium">
|
||||||
|
{transaction.note || t('noDescription')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(transaction.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-lg font-bold ${getTransactionColor(transaction.amount)}`}>
|
||||||
|
{transaction.amount > 0 ? '+' : ''}${Math.abs(transaction.amount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('balance')}: ${transaction.balance.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t('showing')} {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, total)} {t('of')} {total} {t('transactions')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -364,6 +364,16 @@ export default function ProfilePage() {
|
|||||||
<div className="text-xs font-medium text-green-600 dark:text-green-400">{t('usdCredit')}</div>
|
<div className="text-xs font-medium text-green-600 dark:text-green-400">{t('usdCredit')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-green-200/30 dark:border-green-900/30">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-xs border-green-200/50 dark:border-green-800/50 hover:bg-green-50 dark:hover:bg-green-900/20"
|
||||||
|
onClick={() => window.location.href = '/credits'}
|
||||||
|
>
|
||||||
|
{t('viewTransactionHistory')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan Details */}
|
{/* Plan Details */}
|
||||||
|
@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'
|
|||||||
import { User as SupabaseUser } from '@supabase/supabase-js'
|
import { User as SupabaseUser } from '@supabase/supabase-js'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { LegacyAvatar } from '@/components/ui/avatar'
|
import { LegacyAvatar } from '@/components/ui/avatar'
|
||||||
import { ChevronDown, User, LogOut, Settings, CreditCard } from 'lucide-react'
|
import { ChevronDown, User, LogOut, Settings, CreditCard, Receipt } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@ -71,6 +71,18 @@ export function MobileUserMenu({
|
|||||||
<span>{t('profile')}</span>
|
<span>{t('profile')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:outline-none"
|
||||||
|
)}
|
||||||
|
onClick={() => router.push('/credits')}
|
||||||
|
>
|
||||||
|
<Receipt className="h-4 w-4" />
|
||||||
|
<span>{t('credits')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors",
|
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors",
|
||||||
@ -214,6 +226,22 @@ export function UserAvatarDropdown({
|
|||||||
<span>{t('profile')}</span>
|
<span>{t('profile')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-sm transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:outline-none"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
router.push('/credits')
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Receipt className="h-4 w-4" />
|
||||||
|
<span>{t('credits')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-sm transition-colors",
|
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-sm transition-colors",
|
||||||
|
@ -1,235 +0,0 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
export type CreditType = 'system_gift' | 'subscription_monthly' | 'user_purchase'
|
|
||||||
|
|
||||||
export interface CreditSummary {
|
|
||||||
totalBalance: number
|
|
||||||
activeCredits: {
|
|
||||||
id: string
|
|
||||||
amount: number
|
|
||||||
type: CreditType
|
|
||||||
note: string | null
|
|
||||||
expiresAt: Date | null
|
|
||||||
createdAt: Date
|
|
||||||
}[]
|
|
||||||
expiredCredits: {
|
|
||||||
id: string
|
|
||||||
amount: number
|
|
||||||
type: CreditType
|
|
||||||
note: string | null
|
|
||||||
expiresAt: Date | null
|
|
||||||
createdAt: Date
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户的信用额度总览
|
|
||||||
*/
|
|
||||||
export async function getUserCreditSummary(userId: string): Promise<CreditSummary> {
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
// 获取所有信用记录
|
|
||||||
const allCredits = await prisma.credit.findMany({
|
|
||||||
where: { userId, isActive: true },
|
|
||||||
orderBy: { createdAt: 'desc' }
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeCredits = []
|
|
||||||
const expiredCredits = []
|
|
||||||
let totalBalance = 0
|
|
||||||
|
|
||||||
for (const credit of allCredits) {
|
|
||||||
const creditData = {
|
|
||||||
id: credit.id,
|
|
||||||
amount: credit.amount,
|
|
||||||
type: credit.type as CreditType,
|
|
||||||
note: credit.note,
|
|
||||||
expiresAt: credit.expiresAt,
|
|
||||||
createdAt: credit.createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否过期
|
|
||||||
if (credit.expiresAt && credit.expiresAt < now) {
|
|
||||||
expiredCredits.push(creditData)
|
|
||||||
// 标记过期的信用为非激活状态
|
|
||||||
await prisma.credit.update({
|
|
||||||
where: { id: credit.id },
|
|
||||||
data: { isActive: false }
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
activeCredits.push(creditData)
|
|
||||||
totalBalance += credit.amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalBalance,
|
|
||||||
activeCredits,
|
|
||||||
expiredCredits
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为新用户添加系统赠送的5USD信用额度(1个月后过期)
|
|
||||||
*/
|
|
||||||
export async function addSystemGiftCredit(userId: string): Promise<void> {
|
|
||||||
const expiresAt = new Date()
|
|
||||||
expiresAt.setMonth(expiresAt.getMonth() + 1) // 1个月后过期
|
|
||||||
|
|
||||||
await prisma.credit.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
amount: 5.0,
|
|
||||||
type: 'system_gift',
|
|
||||||
note: '系统赠送 - 新用户礼包',
|
|
||||||
expiresAt,
|
|
||||||
isActive: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为Pro用户添加月度信用额度
|
|
||||||
*/
|
|
||||||
export async function addMonthlySubscriptionCredit(userId: string): Promise<void> {
|
|
||||||
const expiresAt = new Date()
|
|
||||||
expiresAt.setMonth(expiresAt.getMonth() + 1) // 1个月后过期
|
|
||||||
|
|
||||||
await prisma.credit.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
amount: 20.0,
|
|
||||||
type: 'subscription_monthly',
|
|
||||||
note: 'Pro套餐月度额度',
|
|
||||||
expiresAt,
|
|
||||||
isActive: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户购买信用额度(永久有效)
|
|
||||||
*/
|
|
||||||
export async function addUserPurchaseCredit(
|
|
||||||
userId: string,
|
|
||||||
amount: number,
|
|
||||||
note?: string
|
|
||||||
): Promise<void> {
|
|
||||||
await prisma.credit.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
amount,
|
|
||||||
type: 'user_purchase',
|
|
||||||
note: note || '用户充值',
|
|
||||||
expiresAt: null, // 永久有效
|
|
||||||
isActive: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查Pro用户是否需要刷新月度额度
|
|
||||||
*/
|
|
||||||
export async function checkAndRefreshProMonthlyCredit(userId: string): Promise<boolean> {
|
|
||||||
// 获取用户信息
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { subscribePlan: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user || user.subscribePlan !== 'pro') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找最近的月度订阅额度
|
|
||||||
const lastMonthlyCredit = await prisma.credit.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
type: 'subscription_monthly',
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' }
|
|
||||||
})
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
// 如果没有月度额度记录,或者最近的已过期,则添加新的
|
|
||||||
if (!lastMonthlyCredit || (lastMonthlyCredit.expiresAt && lastMonthlyCredit.expiresAt < now)) {
|
|
||||||
await addMonthlySubscriptionCredit(userId)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消费信用额度
|
|
||||||
*/
|
|
||||||
export async function consumeCredit(userId: string, amount: number): Promise<boolean> {
|
|
||||||
const creditSummary = await getUserCreditSummary(userId)
|
|
||||||
|
|
||||||
if (creditSummary.totalBalance < amount) {
|
|
||||||
return false // 余额不足
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按优先级消费额度:先消费即将过期的,再消费永久的
|
|
||||||
const sortedCredits = creditSummary.activeCredits.sort((a, b) => {
|
|
||||||
// 有过期时间的排在前面,没有过期时间的排在后面
|
|
||||||
if (a.expiresAt && !b.expiresAt) return -1
|
|
||||||
if (!a.expiresAt && b.expiresAt) return 1
|
|
||||||
if (a.expiresAt && b.expiresAt) {
|
|
||||||
return a.expiresAt.getTime() - b.expiresAt.getTime()
|
|
||||||
}
|
|
||||||
return a.createdAt.getTime() - b.createdAt.getTime()
|
|
||||||
})
|
|
||||||
|
|
||||||
let remainingAmount = amount
|
|
||||||
|
|
||||||
for (const credit of sortedCredits) {
|
|
||||||
if (remainingAmount <= 0) break
|
|
||||||
|
|
||||||
const consumeFromThis = Math.min(credit.amount, remainingAmount)
|
|
||||||
const newAmount = credit.amount - consumeFromThis
|
|
||||||
|
|
||||||
if (newAmount <= 0) {
|
|
||||||
// 这个额度用完了,标记为非激活
|
|
||||||
await prisma.credit.update({
|
|
||||||
where: { id: credit.id },
|
|
||||||
data: { isActive: false }
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// 更新剩余额度
|
|
||||||
await prisma.credit.update({
|
|
||||||
where: { id: credit.id },
|
|
||||||
data: { amount: newAmount }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
remainingAmount -= consumeFromThis
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取订阅计划的限制
|
|
||||||
* @deprecated 请使用 SubscriptionService.getUserPermissions() 替代
|
|
||||||
*/
|
|
||||||
export function getSubscriptionLimits(plan: string) {
|
|
||||||
// 保留向后兼容性,但建议使用新的订阅服务
|
|
||||||
switch (plan) {
|
|
||||||
case 'pro':
|
|
||||||
return {
|
|
||||||
promptLimit: 5000, // 修正为正确的 Pro 限制
|
|
||||||
maxVersionLimit: 10,
|
|
||||||
monthlyCredit: 20.0
|
|
||||||
}
|
|
||||||
case 'free':
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
promptLimit: 500, // 修正为正确的免费限制
|
|
||||||
maxVersionLimit: 3,
|
|
||||||
monthlyCredit: 0.0 // 免费版没有月度额度
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
300
src/lib/services/credit.ts
Normal file
300
src/lib/services/credit.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
export interface CreditTransaction {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
amount: number
|
||||||
|
balance: number
|
||||||
|
type: string
|
||||||
|
category?: string | null
|
||||||
|
note?: string | null
|
||||||
|
referenceId?: string | null
|
||||||
|
referenceType?: string | null
|
||||||
|
expiresAt?: Date | null
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCreditTransactionOptions {
|
||||||
|
userId: string
|
||||||
|
amount: number
|
||||||
|
type: 'system_gift' | 'subscription_monthly' | 'user_purchase' | 'consumption'
|
||||||
|
category?: string
|
||||||
|
note?: string
|
||||||
|
referenceId?: string
|
||||||
|
referenceType?: string
|
||||||
|
expiresAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户当前余额
|
||||||
|
*/
|
||||||
|
export async function getUserBalance(userId: string): Promise<number> {
|
||||||
|
const latestCredit = await prisma.credit.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { balance: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return latestCredit?.balance ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建信用交易记录并更新余额
|
||||||
|
*/
|
||||||
|
export async function createCreditTransaction(
|
||||||
|
options: CreateCreditTransactionOptions
|
||||||
|
): Promise<CreditTransaction> {
|
||||||
|
const { userId, amount, type, category, note, referenceId, referenceType, expiresAt } = options
|
||||||
|
|
||||||
|
// 获取当前余额
|
||||||
|
const currentBalance = await getUserBalance(userId)
|
||||||
|
|
||||||
|
// 计算新余额
|
||||||
|
const newBalance = currentBalance + amount
|
||||||
|
|
||||||
|
// 检查余额是否足够(对于消费类型)
|
||||||
|
if (type === 'consumption' && newBalance < 0) {
|
||||||
|
throw new Error('Insufficient credit balance')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建交易记录
|
||||||
|
const credit = await prisma.credit.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
amount,
|
||||||
|
balance: newBalance,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
note,
|
||||||
|
referenceId,
|
||||||
|
referenceType,
|
||||||
|
expiresAt,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return credit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 充值信用
|
||||||
|
*/
|
||||||
|
export async function addCredit(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
type: 'system_gift' | 'subscription_monthly' | 'user_purchase',
|
||||||
|
note?: string,
|
||||||
|
expiresAt?: Date
|
||||||
|
): Promise<CreditTransaction> {
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw new Error('Amount must be positive')
|
||||||
|
}
|
||||||
|
|
||||||
|
return createCreditTransaction({
|
||||||
|
userId,
|
||||||
|
amount,
|
||||||
|
type,
|
||||||
|
note,
|
||||||
|
expiresAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消费信用
|
||||||
|
*/
|
||||||
|
export async function consumeCredit(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
category: string,
|
||||||
|
referenceId?: string,
|
||||||
|
referenceType?: string,
|
||||||
|
note?: string
|
||||||
|
): Promise<CreditTransaction> {
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw new Error('Amount must be positive')
|
||||||
|
}
|
||||||
|
|
||||||
|
return createCreditTransaction({
|
||||||
|
userId,
|
||||||
|
amount: -amount, // 负数表示消费
|
||||||
|
type: 'consumption',
|
||||||
|
category,
|
||||||
|
referenceId,
|
||||||
|
referenceType,
|
||||||
|
note
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信用交易记录(带分页和筛选)
|
||||||
|
*/
|
||||||
|
export interface GetCreditTransactionsOptions {
|
||||||
|
userId: string
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
type?: string
|
||||||
|
category?: string
|
||||||
|
sortBy?: 'createdAt' | 'amount' | 'balance'
|
||||||
|
sortOrder?: 'asc' | 'desc'
|
||||||
|
startDate?: Date
|
||||||
|
endDate?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditTransactionsResult {
|
||||||
|
transactions: CreditTransaction[]
|
||||||
|
total: number
|
||||||
|
currentBalance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCreditTransactions(
|
||||||
|
options: GetCreditTransactionsOptions
|
||||||
|
): Promise<CreditTransactionsResult> {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
|
// 构建筛选条件
|
||||||
|
const where: Prisma.CreditWhereInput = {
|
||||||
|
userId,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
where.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
where.category = category
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.createdAt = {}
|
||||||
|
if (startDate) {
|
||||||
|
where.createdAt.gte = startDate
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
where.createdAt.lte = endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
const [transactions, total] = await Promise.all([
|
||||||
|
prisma.credit.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { [sortBy]: sortOrder },
|
||||||
|
skip: offset,
|
||||||
|
take: limit
|
||||||
|
}),
|
||||||
|
prisma.credit.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
// 获取当前余额
|
||||||
|
const currentBalance = await getUserBalance(userId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactions,
|
||||||
|
total,
|
||||||
|
currentBalance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信用统计信息
|
||||||
|
*/
|
||||||
|
export interface CreditStats {
|
||||||
|
currentBalance: number
|
||||||
|
totalEarned: number
|
||||||
|
totalSpent: number
|
||||||
|
thisMonthEarned: number
|
||||||
|
thisMonthSpent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCreditStats(userId: string): Promise<CreditStats> {
|
||||||
|
const currentBalance = await getUserBalance(userId)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
|
||||||
|
// 获取总收入和支出
|
||||||
|
const totalStats = await prisma.credit.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
amount: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取本月收入和支出
|
||||||
|
const monthlyStats = await prisma.credit.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: {
|
||||||
|
gte: startOfMonth
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
amount: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalEarned = totalStats
|
||||||
|
.filter(stat => stat.type !== 'consumption')
|
||||||
|
.reduce((sum, stat) => sum + (stat._sum.amount || 0), 0)
|
||||||
|
|
||||||
|
const totalSpent = Math.abs(totalStats
|
||||||
|
.filter(stat => stat.type === 'consumption')
|
||||||
|
.reduce((sum, stat) => sum + (stat._sum.amount || 0), 0))
|
||||||
|
|
||||||
|
const thisMonthEarned = monthlyStats
|
||||||
|
.filter(stat => stat.type !== 'consumption')
|
||||||
|
.reduce((sum, stat) => sum + (stat._sum.amount || 0), 0)
|
||||||
|
|
||||||
|
const thisMonthSpent = Math.abs(monthlyStats
|
||||||
|
.filter(stat => stat.type === 'consumption')
|
||||||
|
.reduce((sum, stat) => sum + (stat._sum.amount || 0), 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentBalance,
|
||||||
|
totalEarned,
|
||||||
|
totalSpent,
|
||||||
|
thisMonthEarned,
|
||||||
|
thisMonthSpent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟器运行时消费信用
|
||||||
|
*/
|
||||||
|
export async function consumeCreditForSimulation(
|
||||||
|
userId: string,
|
||||||
|
cost: number,
|
||||||
|
simulatorRunId: string,
|
||||||
|
note?: string
|
||||||
|
): Promise<CreditTransaction> {
|
||||||
|
return consumeCredit(
|
||||||
|
userId,
|
||||||
|
cost,
|
||||||
|
'simulation',
|
||||||
|
simulatorRunId,
|
||||||
|
'simulator_run',
|
||||||
|
note || `AI model simulation cost`
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user