diff --git a/.gitignore b/.gitignore index f390d12..449f031 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ yarn-error.log* next-env.d.ts /src/generated/prisma +.playwright-mcp diff --git a/messages/en.json b/messages/en.json index f0b41e2..73849ba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6,6 +6,7 @@ "plaza": "Plaza", "pricing": "Pricing", "subscription": "Subscription", + "credits": "Credits", "profile": "Profile", "admin": "Admin", "signIn": "Sign In", @@ -105,7 +106,40 @@ "proPlan": "Pro Plan", "usdCredit": "USD", "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": { "title": "AI Prompt Studio", diff --git a/messages/zh.json b/messages/zh.json index 2edfc45..7e2deb3 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -6,6 +6,7 @@ "plaza": "广场", "pricing": "价格", "subscription": "订阅", + "credits": "信用记录", "profile": "个人资料", "admin": "管理员后台", "signIn": "登录", @@ -105,7 +106,40 @@ "proPlan": "专业版", "usdCredit": "美元", "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": { "title": "AI 提示词工作室", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0495ded..8dda3b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -165,15 +165,19 @@ model PromptStats { } model Credit { - id String @id @default(cuid()) - userId String - amount Float // 信用额度数量 - type String // "system_gift", "subscription_monthly", "user_purchase" - note String? // 备注说明 - expiresAt DateTime? // 过期时间,null表示永久有效 - isActive Boolean @default(true) // 是否激活状态 - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + amount Float // 交易金额(正数为充值,负数为消费) + balance Float // 执行该交易后的余额 + type String // "system_gift", "subscription_monthly", "user_purchase", "consumption" + category String? // 消费类别: "simulation", "api_call", "export" 等 + note String? // 备注说明 + referenceId String? // 关联的记录ID(如SimulatorRun的ID) + 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) @@ -266,6 +270,7 @@ model SimulatorRun { outputTokens Int? // 输出token数 totalCost Float? // 总消费 duration Int? // 运行时长(毫秒) + creditId String? // 关联的信用消费记录ID createdAt DateTime @default(now()) completedAt DateTime? diff --git a/scripts/seed-credits.ts b/scripts/seed-credits.ts new file mode 100644 index 0000000..1876a9b --- /dev/null +++ b/scripts/seed-credits.ts @@ -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() \ No newline at end of file diff --git a/src/app/api/credits/stats/route.ts b/src/app/api/credits/stats/route.ts new file mode 100644 index 0000000..bf30f40 --- /dev/null +++ b/src/app/api/credits/stats/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/credits/transactions/route.ts b/src/app/api/credits/transactions/route.ts new file mode 100644 index 0000000..3ab2666 --- /dev/null +++ b/src/app/api/credits/transactions/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/simulator/[id]/execute/route.ts b/src/app/api/simulator/[id]/execute/route.ts index a30b19c..dfe1fda 100644 --- a/src/app/api/simulator/[id]/execute/route.ts +++ b/src/app/api/simulator/[id]/execute/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createServerSupabaseClient } from "@/lib/supabase-server"; import { prisma } from "@/lib/prisma"; import { getPromptContent, calculateCost } from "@/lib/simulator-utils"; +import { consumeCreditForSimulation, getUserBalance } from "@/lib/services/credit"; export async function POST( request: NextRequest, @@ -36,6 +37,17 @@ export async function POST( 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({ where: { id }, @@ -115,6 +127,30 @@ export async function POST( const duration = Date.now() - startTime; 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({ where: { id }, data: { @@ -124,6 +160,7 @@ export async function POST( outputTokens, totalCost: actualCost, duration, + creditId: creditTransaction.id, completedAt: new Date(), }, }); diff --git a/src/app/api/users/credits/route.ts b/src/app/api/users/credits/route.ts index 60e6c82..4d9afe8 100644 --- a/src/app/api/users/credits/route.ts +++ b/src/app/api/users/credits/route.ts @@ -1,23 +1,71 @@ 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) { try { - const { searchParams } = new URL(request.url) - const userId = searchParams.get('userId') + const supabase = await createServerSupabaseClient() + const { data: { user }, error: authError } = await supabase.auth.getUser() - if (!userId) { - return NextResponse.json({ error: 'User ID is required' }, { status: 401 }) + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // 检查Pro用户是否需要刷新月度额度 - await checkAndRefreshProMonthlyCredit(userId) + const { searchParams } = new URL(request.url) + const userId = searchParams.get('userId') || user.id - // 获取信用额度概览 - const creditSummary = await getUserCreditSummary(userId) + if (userId !== user.id) { + 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) { console.error('Error fetching user credits:', error) diff --git a/src/app/api/users/sync/route.ts b/src/app/api/users/sync/route.ts index 65dc687..79f6c18 100644 --- a/src/app/api/users/sync/route.ts +++ b/src/app/api/users/sync/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { createServerSupabaseClient } from '@/lib/supabase-server' -import { addSystemGiftCredit } from '@/lib/credits' +import { addCredit } from '@/lib/services/credit' // POST /api/users/sync - 同步Supabase用户到Prisma数据库 export async function POST() { @@ -50,7 +50,16 @@ export async function POST() { }) // 为新用户添加系统赠送的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({ message: 'User created successfully', diff --git a/src/app/credits/page.tsx b/src/app/credits/page.tsx new file mode 100644 index 0000000..611ca45 --- /dev/null +++ b/src/app/credits/page.tsx @@ -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([]) + const [stats, setStats] = useState(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('') + const [categoryFilter, setCategoryFilter] = useState('') + 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 + case 'subscription_monthly': + return + case 'user_purchase': + return + default: + return + } + } else { + return + } + } + + 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 ( +
+
+
+
+ +
+
+
+ ) + } + + return ( +
+
+ +
+ {/* Header */} +
+

{t('title')}

+

{t('subtitle')}

+
+ + {/* Stats Cards */} + {stats && ( +
+ +
+
+ +
+
+

{t('currentBalance')}

+

+ ${stats.currentBalance.toFixed(2)} +

+
+
+
+ + +
+
+ +
+
+

{t('totalEarned')}

+

${stats.totalEarned.toFixed(2)}

+
+
+
+ + +
+
+ +
+
+

{t('totalSpent')}

+

${stats.totalSpent.toFixed(2)}

+
+
+
+ + +
+
+ +
+
+

{t('thisMonthEarned')}

+

${stats.thisMonthEarned.toFixed(2)}

+
+
+
+ + +
+
+ +
+
+

{t('thisMonthSpent')}

+

${stats.thisMonthSpent.toFixed(2)}

+
+
+
+
+ )} + + {/* Filters */} + +
+
+ + {t('filters')}: +
+ + + + + +
+ + +
+
+
+ + {/* Transactions List */} + +
+

{t('transactionHistory')}

+ + {isLoading ? ( +
+ +
+ ) : transactions.length === 0 ? ( +
+ +

{t('noTransactions')}

+
+ ) : ( +
+ {transactions.map((transaction) => ( +
+
+
+ {getTransactionIcon(transaction.type, transaction.amount)} +
+ +
+
+ + {formatTransactionType(transaction.type)} + + {transaction.category && ( + + {transaction.category} + + )} +
+ +

+ {transaction.note || t('noDescription')} +

+

+ {formatDate(transaction.createdAt)} +

+
+
+ +
+

+ {transaction.amount > 0 ? '+' : ''}${Math.abs(transaction.amount).toFixed(2)} +

+

+ {t('balance')}: ${transaction.balance.toFixed(2)} +

+
+
+ ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ {t('showing')} {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, total)} {t('of')} {total} {t('transactions')} +
+ +
+ + +
+ {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 ( + + ) + })} +
+ + +
+
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 5b15938..7802a9c 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -364,6 +364,16 @@ export default function ProfilePage() {
{t('usdCredit')}
+
+ +
{/* Plan Details */} diff --git a/src/components/ui/user-avatar-dropdown.tsx b/src/components/ui/user-avatar-dropdown.tsx index 7e5d440..f22f6df 100644 --- a/src/components/ui/user-avatar-dropdown.tsx +++ b/src/components/ui/user-avatar-dropdown.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl' import { User as SupabaseUser } from '@supabase/supabase-js' import { Button } from '@/components/ui/button' 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 { useAuthUser } from '@/hooks/useAuthUser' import { useRouter } from 'next/navigation' @@ -71,6 +71,18 @@ export function MobileUserMenu({ {t('profile')} + + + +