From 1d6b1ae0e1d373159c05af81726c4f05f12373ab Mon Sep 17 00:00:00 2001 From: songtianlun Date: Thu, 28 Aug 2025 00:00:53 +0800 Subject: [PATCH] Add subscribe admin --- prisma/schema.prisma | 13 +- src/app/admin/page.tsx | 19 + src/app/admin/plans/page.tsx | 461 ++++++++++++++++++ src/app/api/admin/models/route.ts | 79 +-- src/app/api/admin/subscription-plans/route.ts | 4 + src/app/api/simulator/[id]/execute/route.ts | 15 +- src/lib/fal.ts | 4 +- src/lib/simulator-utils.ts | 32 +- src/lib/uniapi.ts | 2 +- 9 files changed, 554 insertions(+), 75 deletions(-) create mode 100644 src/app/admin/plans/page.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8354620..b050f92 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,7 @@ model SubscriptionPlan { stripePriceId String? @unique // Stripe 价格 ID isActive Boolean @default(true) // 是否激活 sortOrder Int @default(0) // 排序顺序 + costMultiplier Float @default(1.0) // 费用倍率,默认1倍 // 权益配置 (JSON 格式存储) features Json // 功能特性配置 @@ -213,11 +214,11 @@ model Subscription { @@map("subscriptions") } -// AI 模型配置表 - 每个模型记录直接关联一个套餐 +// AI 模型配置表 - 所有模型默认可用于所有套餐 model Model { id String @id @default(cuid()) - subscriptionPlanId String // 关联的套餐 ID - modelId String // OpenRouter 的模型 ID,如 "openai/gpt-4" + subscriptionPlanId String @default("free") // 默认关联到 free 套餐 + modelId String @unique // 全局唯一的模型 ID,如 "openai/gpt-4" name String // 显示名称,如 "GPT-4" provider String // 提供商,如 "OpenAI" serviceProvider String @default("openrouter") // 服务提供者,如 "openrouter", "replicate" @@ -235,12 +236,8 @@ model Model { // 关联关系 subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade) + simulatorRuns SimulatorRun[] - // 关联关系 - simulatorRuns SimulatorRun[] - - // 同一个套餐内不能有相同的模型ID - @@unique([subscriptionPlanId, modelId]) @@map("models") } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 79f4deb..bebfc69 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -173,6 +173,25 @@ export default function AdminDashboard() { + +
+
+ +
+
+
+ Subscription Plans +
+
+ Manage subscription plans and cost multipliers +
+
+
+ + + limits: Record + createdAt: string + updatedAt: string + _count?: { + users: number + subscriptions: number + models: number + } +} + +export default function AdminPlansPage() { + const { userData, loading: authLoading } = useAuthUser() + const [plans, setPlans] = useState([]) + const [loading, setLoading] = useState(true) + const [editingPlan, setEditingPlan] = useState(null) + const [isCreating, setIsCreating] = useState(false) + const [formData, setFormData] = useState({ + id: '', + name: '', + displayName: '', + description: '', + price: 0, + currency: 'usd', + interval: 'month', + stripePriceId: '', + isActive: true, + sortOrder: 0, + costMultiplier: 1.0, + features: '{}', + limits: '{}' + }) + + useEffect(() => { + // AdminLayout已经处理了权限验证,这里只需要在认证完成后获取数据 + if (!authLoading && userData && userData.isAdmin) { + fetchPlans() + } + }, [userData, authLoading]) + + const fetchPlans = async () => { + try { + const response = await fetch('/api/admin/subscription-plans') + if (!response.ok) { + throw new Error('Failed to fetch plans') + } + const data = await response.json() + setPlans(data.plans || []) + } catch (error) { + alert('Failed to fetch subscription plans') + } finally { + setLoading(false) + } + } + + const resetForm = () => { + setFormData({ + id: '', + name: '', + displayName: '', + description: '', + price: 0, + currency: 'usd', + interval: 'month', + stripePriceId: '', + isActive: true, + sortOrder: 0, + costMultiplier: 1.0, + features: '{}', + limits: '{}' + }) + } + + const handleEdit = (plan: SubscriptionPlan) => { + setEditingPlan(plan) + setFormData({ + id: plan.id, + name: plan.name, + displayName: plan.displayName, + description: plan.description || '', + price: plan.price, + currency: plan.currency, + interval: plan.interval, + stripePriceId: plan.stripePriceId || '', + isActive: plan.isActive, + sortOrder: plan.sortOrder, + costMultiplier: plan.costMultiplier, + features: JSON.stringify(plan.features, null, 2), + limits: JSON.stringify(plan.limits, null, 2) + }) + setIsCreating(false) + } + + const handleCreate = () => { + setIsCreating(true) + setEditingPlan(null) + resetForm() + } + + const handleCancel = () => { + setEditingPlan(null) + setIsCreating(false) + resetForm() + } + + const handleSave = async () => { + try { + // 验证 JSON 格式 + let features, limits + try { + features = JSON.parse(formData.features) + limits = JSON.parse(formData.limits) + } catch { + alert('Invalid JSON format in features or limits') + return + } + + const planData = { + ...formData, + features, + limits + } + + const url = isCreating + ? '/api/admin/subscription-plans' + : `/api/admin/subscription-plans?id=${editingPlan?.id}` + + const method = isCreating ? 'POST' : 'PUT' + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(planData) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to save plan') + } + + alert(isCreating ? 'Plan created successfully' : 'Plan updated successfully') + + fetchPlans() + handleCancel() + } catch (error) { + alert(error instanceof Error ? error.message : 'Failed to save plan') + } + } + + const handleDelete = async (planId: string) => { + if (!confirm('Are you sure you want to delete this plan?')) { + return + } + + try { + const response = await fetch(`/api/admin/subscription-plans?id=${planId}`, { + method: 'DELETE' + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to delete plan') + } + + alert('Plan deleted successfully') + + fetchPlans() + } catch (error) { + alert(error instanceof Error ? error.message : 'Failed to delete plan') + } + } + + // AdminLayout已经处理了认证和权限检查 + if (authLoading || loading) { + return ( +
+
+
+ ) + } + + // 如果没有权限,AdminLayout会重定向,这里不需要额外处理 + if (!userData || !userData.isAdmin) { + return null + } + + return ( +
+
+
+

Subscription Plans

+

Manage subscription plans and pricing

+
+ +
+ + {/* Plan Editor Form */} + {(isCreating || editingPlan) && ( + + + {isCreating ? 'Create New Plan' : 'Edit Plan'} + + {isCreating ? 'Create a new subscription plan' : 'Update subscription plan details'} + + + +
+
+ + setFormData(prev => ({ ...prev, id: e.target.value }))} + placeholder="e.g., free, pro, premium" + disabled={!isCreating} + /> +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="Internal plan name" + /> +
+
+ + setFormData(prev => ({ ...prev, displayName: e.target.value }))} + placeholder="User-facing plan name" + /> +
+
+ + setFormData(prev => ({ ...prev, price: parseFloat(e.target.value) || 0 }))} + placeholder="0.00" + /> +
+
+ + setFormData(prev => ({ ...prev, currency: e.target.value }))} + placeholder="usd" + /> +
+
+ + setFormData(prev => ({ ...prev, interval: e.target.value }))} + placeholder="month, year" + /> +
+
+ + setFormData(prev => ({ ...prev, costMultiplier: parseFloat(e.target.value) || 1.0 }))} + placeholder="1.0" + /> +

AI usage cost multiplier (e.g., 10.0 for free, 3.0 for pro)

+
+
+ + setFormData(prev => ({ ...prev, sortOrder: parseInt(e.target.value) || 0 }))} + placeholder="0" + /> +
+
+ +
+ +