Add subscribe admin
This commit is contained in:
parent
ada862704b
commit
1d6b1ae0e1
@ -58,6 +58,7 @@ model SubscriptionPlan {
|
|||||||
stripePriceId String? @unique // Stripe 价格 ID
|
stripePriceId String? @unique // Stripe 价格 ID
|
||||||
isActive Boolean @default(true) // 是否激活
|
isActive Boolean @default(true) // 是否激活
|
||||||
sortOrder Int @default(0) // 排序顺序
|
sortOrder Int @default(0) // 排序顺序
|
||||||
|
costMultiplier Float @default(1.0) // 费用倍率,默认1倍
|
||||||
|
|
||||||
// 权益配置 (JSON 格式存储)
|
// 权益配置 (JSON 格式存储)
|
||||||
features Json // 功能特性配置
|
features Json // 功能特性配置
|
||||||
@ -213,11 +214,11 @@ model Subscription {
|
|||||||
@@map("subscriptions")
|
@@map("subscriptions")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 模型配置表 - 每个模型记录直接关联一个套餐
|
// AI 模型配置表 - 所有模型默认可用于所有套餐
|
||||||
model Model {
|
model Model {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
subscriptionPlanId String // 关联的套餐 ID
|
subscriptionPlanId String @default("free") // 默认关联到 free 套餐
|
||||||
modelId String // OpenRouter 的模型 ID,如 "openai/gpt-4"
|
modelId String @unique // 全局唯一的模型 ID,如 "openai/gpt-4"
|
||||||
name String // 显示名称,如 "GPT-4"
|
name String // 显示名称,如 "GPT-4"
|
||||||
provider String // 提供商,如 "OpenAI"
|
provider String // 提供商,如 "OpenAI"
|
||||||
serviceProvider String @default("openrouter") // 服务提供者,如 "openrouter", "replicate"
|
serviceProvider String @default("openrouter") // 服务提供者,如 "openrouter", "replicate"
|
||||||
@ -235,12 +236,8 @@ model Model {
|
|||||||
|
|
||||||
// 关联关系
|
// 关联关系
|
||||||
subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade)
|
subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade)
|
||||||
|
simulatorRuns SimulatorRun[]
|
||||||
|
|
||||||
// 关联关系
|
|
||||||
simulatorRuns SimulatorRun[]
|
|
||||||
|
|
||||||
// 同一个套餐内不能有相同的模型ID
|
|
||||||
@@unique([subscriptionPlanId, modelId])
|
|
||||||
@@map("models")
|
@@map("models")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,6 +173,25 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/admin/plans"
|
||||||
|
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-md bg-orange-50 dark:bg-orange-900/30 group-hover:bg-orange-100 dark:group-hover:bg-orange-900/50 transition-colors">
|
||||||
|
<Users className="h-4 w-4 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-foreground group-hover:text-orange-600 transition-colors">
|
||||||
|
Subscription Plans
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Manage subscription plans and cost multipliers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/debug/cache"
|
href="/debug/cache"
|
||||||
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
||||||
|
461
src/app/admin/plans/page.tsx
Normal file
461
src/app/admin/plans/page.tsx
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Trash2, Edit, Plus, Save, X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SubscriptionPlan {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
description?: string
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
interval: string
|
||||||
|
stripePriceId?: string
|
||||||
|
isActive: boolean
|
||||||
|
sortOrder: number
|
||||||
|
costMultiplier: number
|
||||||
|
features: Record<string, unknown>
|
||||||
|
limits: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
_count?: {
|
||||||
|
users: number
|
||||||
|
subscriptions: number
|
||||||
|
models: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPlansPage() {
|
||||||
|
const { userData, loading: authLoading } = useAuthUser()
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(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 (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有权限,AdminLayout会重定向,这里不需要额外处理
|
||||||
|
if (!userData || !userData.isAdmin) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Subscription Plans</h1>
|
||||||
|
<p className="text-muted-foreground">Manage subscription plans and pricing</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan Editor Form */}
|
||||||
|
{(isCreating || editingPlan) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{isCreating ? 'Create New Plan' : 'Edit Plan'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isCreating ? 'Create a new subscription plan' : 'Update subscription plan details'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="id">Plan ID</Label>
|
||||||
|
<Input
|
||||||
|
id="id"
|
||||||
|
value={formData.id}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, id: e.target.value }))}
|
||||||
|
placeholder="e.g., free, pro, premium"
|
||||||
|
disabled={!isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Internal Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="Internal plan name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="displayName">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
value={formData.displayName}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, displayName: e.target.value }))}
|
||||||
|
placeholder="User-facing plan name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price">Price</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, price: parseFloat(e.target.value) || 0 }))}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="currency">Currency</Label>
|
||||||
|
<Input
|
||||||
|
id="currency"
|
||||||
|
value={formData.currency}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, currency: e.target.value }))}
|
||||||
|
placeholder="usd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="interval">Billing Interval</Label>
|
||||||
|
<Input
|
||||||
|
id="interval"
|
||||||
|
value={formData.interval}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, interval: e.target.value }))}
|
||||||
|
placeholder="month, year"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="costMultiplier">Cost Multiplier</Label>
|
||||||
|
<Input
|
||||||
|
id="costMultiplier"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={formData.costMultiplier}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, costMultiplier: parseFloat(e.target.value) || 1.0 }))}
|
||||||
|
placeholder="1.0"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">AI usage cost multiplier (e.g., 10.0 for free, 3.0 for pro)</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sortOrder">Sort Order</Label>
|
||||||
|
<Input
|
||||||
|
id="sortOrder"
|
||||||
|
type="number"
|
||||||
|
value={formData.sortOrder}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, sortOrder: parseInt(e.target.value) || 0 }))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="Plan description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="stripePriceId">Stripe Price ID</Label>
|
||||||
|
<Input
|
||||||
|
id="stripePriceId"
|
||||||
|
value={formData.stripePriceId}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, stripePriceId: e.target.value }))}
|
||||||
|
placeholder="price_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="features">Features (JSON)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="features"
|
||||||
|
value={formData.features}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, features: e.target.value }))}
|
||||||
|
placeholder='{"promptLimit": 20, "versionLimit": 3}'
|
||||||
|
rows={4}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="limits">Limits (JSON)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="limits"
|
||||||
|
value={formData.limits}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, limits: e.target.value }))}
|
||||||
|
placeholder='{"maxPromptsPerDay": 100, "maxAPICallsPerMonth": 1000}'
|
||||||
|
rows={4}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, isActive: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isActive">Active</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button onClick={handleSave} className="flex items-center gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isCreating ? 'Create' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCancel} variant="outline" className="flex items-center gap-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plans List */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<Card key={plan.id} className="relative">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{plan.displayName}
|
||||||
|
<Badge variant={plan.isActive ? 'default' : 'secondary'}>
|
||||||
|
{plan.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{plan.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleEdit(plan)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(plan.id)}
|
||||||
|
disabled={plan._count && (plan._count.users > 0 || plan._count.subscriptions > 0)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">ID</p>
|
||||||
|
<p className="text-muted-foreground">{plan.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Price</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{plan.price === 0 ? 'Free' : `$${plan.price}/${plan.interval}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Cost Multiplier</p>
|
||||||
|
<p className="text-muted-foreground">{plan.costMultiplier}x</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Sort Order</p>
|
||||||
|
<p className="text-muted-foreground">{plan.sortOrder}</p>
|
||||||
|
</div>
|
||||||
|
{plan._count && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Users</p>
|
||||||
|
<p className="text-muted-foreground">{plan._count.users}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Subscriptions</p>
|
||||||
|
<p className="text-muted-foreground">{plan._count.subscriptions}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Models</p>
|
||||||
|
<p className="text-muted-foreground">{plan._count.models}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { ReplicateService } from '@/lib/replicate'
|
|||||||
import { UniAPIService } from '@/lib/uniapi'
|
import { UniAPIService } from '@/lib/uniapi'
|
||||||
import { FalService } from '@/lib/fal'
|
import { FalService } from '@/lib/fal'
|
||||||
|
|
||||||
// GET /api/admin/models - 获取所有模型(按套餐分组)
|
// GET /api/admin/models - 获取所有模型
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const models = await prisma.model.findMany({
|
const models = await prisma.model.findMany({
|
||||||
@ -19,7 +19,6 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ subscriptionPlan: { sortOrder: 'asc' } },
|
|
||||||
{ provider: 'asc' },
|
{ provider: 'asc' },
|
||||||
{ name: 'asc' }
|
{ name: 'asc' }
|
||||||
]
|
]
|
||||||
@ -35,30 +34,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/admin/models - 为指定套餐添加模型
|
// POST /api/admin/models - 同步和添加模型
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { action, planId, selectedModels, serviceProvider } = await request.json()
|
const { action, selectedModels, serviceProvider } = await request.json()
|
||||||
|
|
||||||
if (action === 'sync') {
|
if (action === 'sync') {
|
||||||
if (!planId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Plan ID is required for sync action' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证套餐是否存在
|
|
||||||
const plan = await prisma.subscriptionPlan.findUnique({
|
|
||||||
where: { id: planId }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Subscription plan not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = serviceProvider || 'openrouter'
|
const provider = serviceProvider || 'openrouter'
|
||||||
let availableModels: Array<Record<string, unknown>> = []
|
let availableModels: Array<Record<string, unknown>> = []
|
||||||
@ -116,74 +97,62 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Models fetched successfully',
|
message: 'Models fetched successfully',
|
||||||
availableModels,
|
availableModels,
|
||||||
planId,
|
|
||||||
serviceProvider: provider
|
serviceProvider: provider
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
if (!planId || !selectedModels || !Array.isArray(selectedModels)) {
|
if (!selectedModels || !Array.isArray(selectedModels)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Plan ID and selected models are required' },
|
{ error: 'Selected models are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查模型 ID 的唯一性
|
// 检查模型 ID 的全局唯一性
|
||||||
const modelIds = selectedModels.map(model => model.modelId)
|
const modelIds = selectedModels.map(model => model.modelId)
|
||||||
const existingModels = await prisma.model.findMany({
|
const existingModels = await prisma.model.findMany({
|
||||||
where: {
|
where: {
|
||||||
modelId: {
|
modelId: {
|
||||||
in: modelIds
|
in: modelIds
|
||||||
},
|
|
||||||
subscriptionPlanId: {
|
|
||||||
not: planId // 排除当前套餐,允许同一套餐内更新
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
subscriptionPlan: {
|
modelId: true,
|
||||||
select: {
|
serviceProvider: true,
|
||||||
displayName: true
|
name: true
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (existingModels.length > 0) {
|
if (existingModels.length > 0) {
|
||||||
const conflicts = existingModels.map(model => ({
|
const conflicts = existingModels.map(model => ({
|
||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
existingPlan: model.subscriptionPlan.displayName,
|
existingServiceProvider: model.serviceProvider,
|
||||||
existingServiceProvider: model.serviceProvider
|
modelName: model.name
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: 'Model ID conflicts detected',
|
error: 'Model ID conflicts detected',
|
||||||
conflicts: conflicts,
|
conflicts: conflicts,
|
||||||
message: `The following model IDs are already used by other service providers: ${conflicts.map(c => `${c.modelId} (used by ${c.existingServiceProvider} in ${c.existingPlan})`).join(', ')}`
|
message: `The following model IDs already exist: ${conflicts.map(c => `${c.modelId} (${c.modelName} - ${c.existingServiceProvider})`).join(', ')}`
|
||||||
}, { status: 409 })
|
}, { status: 409 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量创建模型记录
|
// 批量创建模型记录 - 所有模型默认绑定到 free 套餐
|
||||||
const results = []
|
const results = []
|
||||||
for (const modelData of selectedModels) {
|
for (const modelData of selectedModels) {
|
||||||
const result = await prisma.model.upsert({
|
const result = await prisma.model.create({
|
||||||
where: {
|
data: {
|
||||||
subscriptionPlanId_modelId: {
|
|
||||||
subscriptionPlanId: planId,
|
|
||||||
modelId: modelData.modelId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
...modelData,
|
...modelData,
|
||||||
subscriptionPlanId: planId,
|
subscriptionPlanId: 'free' // 默认绑定到 free 套餐,所有套餐都可使用
|
||||||
updatedAt: new Date()
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
...modelData,
|
|
||||||
subscriptionPlanId: planId
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptionPlan: true
|
subscriptionPlan: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -191,7 +160,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: `Successfully added ${results.length} models to ${planId} plan`,
|
message: `Successfully added ${results.length} models`,
|
||||||
models: results
|
models: results
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -71,6 +71,7 @@ export async function POST(request: NextRequest) {
|
|||||||
stripePriceId,
|
stripePriceId,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
sortOrder = 0,
|
sortOrder = 0,
|
||||||
|
costMultiplier = 1.0,
|
||||||
features,
|
features,
|
||||||
limits
|
limits
|
||||||
} = await request.json()
|
} = await request.json()
|
||||||
@ -96,6 +97,7 @@ export async function POST(request: NextRequest) {
|
|||||||
stripePriceId,
|
stripePriceId,
|
||||||
isActive,
|
isActive,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
costMultiplier,
|
||||||
features,
|
features,
|
||||||
limits
|
limits
|
||||||
}
|
}
|
||||||
@ -130,6 +132,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
stripePriceId,
|
stripePriceId,
|
||||||
isActive,
|
isActive,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
costMultiplier,
|
||||||
features,
|
features,
|
||||||
limits
|
limits
|
||||||
} = await request.json()
|
} = await request.json()
|
||||||
@ -154,6 +157,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
...(stripePriceId !== undefined && { stripePriceId }),
|
...(stripePriceId !== undefined && { stripePriceId }),
|
||||||
...(isActive !== undefined && { isActive }),
|
...(isActive !== undefined && { isActive }),
|
||||||
...(sortOrder !== undefined && { sortOrder }),
|
...(sortOrder !== undefined && { sortOrder }),
|
||||||
|
...(costMultiplier !== undefined && { costMultiplier }),
|
||||||
...(features && { features }),
|
...(features && { features }),
|
||||||
...(limits && { limits })
|
...(limits && { limits })
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,16 @@ export async function POST(
|
|||||||
prompt: true,
|
prompt: true,
|
||||||
promptVersion: true, // 内部使用,用于获取内容
|
promptVersion: true, // 内部使用,用于获取内容
|
||||||
model: true,
|
model: true,
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
subscriptionPlan: {
|
||||||
|
select: {
|
||||||
|
costMultiplier: true,
|
||||||
|
displayName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -41,7 +51,8 @@ export async function POST(
|
|||||||
|
|
||||||
// Check user's credit balance before execution
|
// Check user's credit balance before execution
|
||||||
const userBalance = await getUserBalance(user.id);
|
const userBalance = await getUserBalance(user.id);
|
||||||
const estimatedCost = calculateCost(0, 100, run.model); // Rough estimate
|
const costMultiplier = run.user.subscriptionPlan.costMultiplier || 1.0;
|
||||||
|
const estimatedCost = calculateCost(0, 100, run.model, costMultiplier); // Rough estimate
|
||||||
|
|
||||||
if (userBalance < estimatedCost) {
|
if (userBalance < estimatedCost) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -292,7 +303,7 @@ export async function POST(
|
|||||||
if (data === '[DONE]') {
|
if (data === '[DONE]') {
|
||||||
// 计算最终数据并更新数据库
|
// 计算最终数据并更新数据库
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const actualCost = calculateCost(inputTokens, outputTokens, run.model);
|
const actualCost = calculateCost(inputTokens, outputTokens, run.model, costMultiplier);
|
||||||
|
|
||||||
// Consume credits for this simulation
|
// Consume credits for this simulation
|
||||||
let creditTransaction;
|
let creditTransaction;
|
||||||
|
@ -5,7 +5,7 @@ interface FalModel {
|
|||||||
category?: string
|
category?: string
|
||||||
type?: 'image' | 'video' | 'audio' | 'text'
|
type?: 'image' | 'video' | 'audio' | 'text'
|
||||||
input_schema?: {
|
input_schema?: {
|
||||||
properties?: Record<string, any>
|
properties?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
pricing?: {
|
pricing?: {
|
||||||
per_request?: number
|
per_request?: number
|
||||||
@ -14,7 +14,7 @@ interface FalModel {
|
|||||||
}
|
}
|
||||||
max_resolution?: string
|
max_resolution?: string
|
||||||
supported_formats?: string[]
|
supported_formats?: string[]
|
||||||
[key: string]: any
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FalModelsResponse {
|
interface FalModelsResponse {
|
||||||
|
@ -44,18 +44,36 @@ export function getOriginalPromptContent(run: Pick<SimulatorRun, 'prompt' | 'pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算实际费用基于模型的定价
|
* 计算实际费用基于模型的定价和用户套餐的费用倍率
|
||||||
*/
|
*/
|
||||||
export function calculateCost(inputTokens: number, outputTokens: number, model: {
|
export function calculateCost(
|
||||||
inputCostPer1k?: number | null
|
inputTokens: number,
|
||||||
outputCostPer1k?: number | null
|
outputTokens: number,
|
||||||
}): number {
|
model: {
|
||||||
|
inputCostPer1k?: number | null
|
||||||
|
outputCostPer1k?: number | null
|
||||||
|
},
|
||||||
|
costMultiplier: number = 1.0
|
||||||
|
): number {
|
||||||
const inputCostPer1k = model.inputCostPer1k || 0
|
const inputCostPer1k = model.inputCostPer1k || 0
|
||||||
const outputCostPer1k = model.outputCostPer1k || 0
|
const outputCostPer1k = model.outputCostPer1k || 0
|
||||||
|
|
||||||
// 计算费用:token数除以1000再乘以每1k的费用
|
// 计算基础费用:token数除以1000再乘以每1k的费用
|
||||||
const inputCost = (inputTokens / 1000) * inputCostPer1k
|
const inputCost = (inputTokens / 1000) * inputCostPer1k
|
||||||
const outputCost = (outputTokens / 1000) * outputCostPer1k
|
const outputCost = (outputTokens / 1000) * outputCostPer1k
|
||||||
|
const baseCost = inputCost + outputCost
|
||||||
|
|
||||||
return inputCost + outputCost
|
// 应用套餐费用倍率
|
||||||
|
return baseCost * costMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向后兼容的函数,不使用费用倍率
|
||||||
|
* @deprecated 请使用带有 costMultiplier 参数的新版本
|
||||||
|
*/
|
||||||
|
export function calculateBaseCost(inputTokens: number, outputTokens: number, model: {
|
||||||
|
inputCostPer1k?: number | null
|
||||||
|
outputCostPer1k?: number | null
|
||||||
|
}): number {
|
||||||
|
return calculateCost(inputTokens, outputTokens, model, 1.0)
|
||||||
}
|
}
|
@ -38,7 +38,7 @@ export class UniAPIService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
await response.text() // Read error text but don't use it
|
||||||
throw new Error(`UniAPI error: ${response.status} ${response.statusText}`)
|
throw new Error(`UniAPI error: ${response.status} ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user