Compare commits
10 Commits
5c569056e2
...
6bf9d67091
Author | SHA1 | Date | |
---|---|---|---|
6bf9d67091 | |||
779f50ea87 | |||
f3e7fb913d | |||
2b04ef3636 | |||
0a067adad2 | |||
9f2434f182 | |||
21bf7add36 | |||
fb2fb08eae | |||
9b6b5cdda9 | |||
c2417c31da |
@ -193,12 +193,21 @@ FAL_API_KEY="fal-1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
| `NEXTAUTH_URL` | NextAuth URL (same as app URL) | `https://prmbr.com` |
|
||||
| `NEXTAUTH_SECRET` | NextAuth secret for JWT signing | Random 32-character string |
|
||||
|
||||
### Optional Variables
|
||||
|
||||
| Variable | Description | Default | Example |
|
||||
|----------|-------------|---------|---------|
|
||||
| `FEE_CALCULATION_MULTIPLIER` | Fee calculation multiplier for AI model costs | `10.0` | `5.0` |
|
||||
|
||||
### Configuration
|
||||
```bash
|
||||
# Application Settings
|
||||
NEXT_PUBLIC_APP_URL="https://prmbr.com"
|
||||
NEXTAUTH_URL="https://prmbr.com"
|
||||
NEXTAUTH_SECRET="your-super-secret-nextauth-secret-32chars"
|
||||
|
||||
# Fee Calculation (Optional - defaults to 10x)
|
||||
FEE_CALCULATION_MULTIPLIER="10.0"
|
||||
```
|
||||
|
||||
### Generating NEXTAUTH_SECRET
|
||||
@ -269,6 +278,9 @@ FAL_API_KEY="fal-1234567890abcdefghijklmnopqrstuvwxyzABCDEF"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="super-secret-nextauth-development-key-32-chars-long"
|
||||
|
||||
# Fee Calculation (Optional - defaults to 10x)
|
||||
FEE_CALCULATION_MULTIPLIER="10.0"
|
||||
```
|
||||
|
||||
### 🌐 Full Production Configuration
|
||||
@ -311,6 +323,9 @@ FAL_API_KEY="fal-production-1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
NEXT_PUBLIC_APP_URL="https://prmbr.com"
|
||||
NEXTAUTH_URL="https://prmbr.com"
|
||||
NEXTAUTH_SECRET="super-secret-production-nextauth-key-32-chars-long-secure"
|
||||
|
||||
# Fee Calculation (Optional - defaults to 10x)
|
||||
FEE_CALCULATION_MULTIPLIER="10.0"
|
||||
```
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
@ -366,8 +366,8 @@
|
||||
"planModelsCount": "models available"
|
||||
},
|
||||
"plaza": {
|
||||
"title": "Prompt Plaza",
|
||||
"subtitle": "Discover and explore prompts shared by the community",
|
||||
"title": "Plaza",
|
||||
"subtitle": "Discover and explore shared prompts and simulation results",
|
||||
"searchPlaceholder": "Search prompts by name or description...",
|
||||
"filterByTag": "Filter by tag",
|
||||
"allTags": "All Tags",
|
||||
@ -472,7 +472,10 @@
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
}
|
||||
},
|
||||
"shareRun": "Share Run",
|
||||
"shared": "Shared",
|
||||
"updating": "Updating..."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
|
@ -367,8 +367,8 @@
|
||||
"planModelsCount": "个可用模型"
|
||||
},
|
||||
"plaza": {
|
||||
"title": "提示词广场",
|
||||
"subtitle": "发现并探索社区分享的提示词",
|
||||
"title": "广场",
|
||||
"subtitle": "发现并探索社区分享的提示词和运行效果",
|
||||
"searchPlaceholder": "按名称或描述搜索提示词...",
|
||||
"filterByTag": "按标签筛选",
|
||||
"allTags": "所有标签",
|
||||
@ -472,7 +472,10 @@
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败"
|
||||
}
|
||||
},
|
||||
"shareRun": "分享运行",
|
||||
"shared": "已分享",
|
||||
"updating": "更新中..."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "出现错误,请重试。",
|
||||
|
@ -259,6 +259,8 @@ model SimulatorRun {
|
||||
output String? // AI响应输出
|
||||
error String? // 错误信息
|
||||
status String @default("pending") // "pending", "running", "completed", "failed"
|
||||
permissions String @default("private") // "private" | "public"
|
||||
visibility String? // "under_review" | "published" | null
|
||||
// 运行配置
|
||||
temperature Float? @default(0.7)
|
||||
maxTokens Int?
|
||||
@ -284,6 +286,9 @@ model SimulatorRun {
|
||||
promptVersion PromptVersion? @relation(fields: [promptVersionId], references: [id], onDelete: SetNull)
|
||||
model Model @relation(fields: [modelId], references: [id])
|
||||
|
||||
// 添加索引优化查询性能
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([userId, status, createdAt(sort: Desc)])
|
||||
@@map("simulator_runs")
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CheckCircle, XCircle, Eye, Calendar, User as UserIcon } from 'lucide-react'
|
||||
import { CheckCircle, XCircle, Eye, Calendar, User as UserIcon, FileText, Play, Zap } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface ReviewPrompt {
|
||||
@ -22,9 +22,34 @@ interface ReviewPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewSimulatorRun {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
userInput: string
|
||||
output?: string | null
|
||||
visibility: string | null
|
||||
createdAt: string
|
||||
user: {
|
||||
username: string | null
|
||||
email: string
|
||||
}
|
||||
prompt: {
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
model: {
|
||||
name: string
|
||||
provider: string
|
||||
outputType?: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const t = useTranslations('admin')
|
||||
const [activeTab, setActiveTab] = useState<'prompts' | 'simulators'>('prompts')
|
||||
const [prompts, setPrompts] = useState<ReviewPrompt[]>([])
|
||||
const [simulatorRuns, setSimulatorRuns] = useState<ReviewSimulatorRun[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchPendingPrompts = async () => {
|
||||
@ -36,13 +61,29 @@ export default function AdminReviewPage() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pending prompts:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPendingSimulators = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/simulators/pending')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSimulatorRuns(data.simulatorRuns)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pending simulator runs:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchPendingPrompts(), fetchPendingSimulators()])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPendingPrompts()
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleApprove = async (promptId: string) => {
|
||||
@ -71,6 +112,40 @@ export default function AdminReviewPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproveSimulator = async (simulatorId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/simulators/${simulatorId}/approve`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (response.ok) {
|
||||
setSimulatorRuns(simulatorRuns.filter(s => s.id !== simulatorId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to approve simulator run:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectSimulator = async (simulatorId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/simulators/${simulatorId}/reject`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (response.ok) {
|
||||
setSimulatorRuns(simulatorRuns.filter(s => s.id !== simulatorId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reject simulator run:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const currentItems = activeTab === 'prompts' ? prompts : simulatorRuns
|
||||
const pendingCount = activeTab === 'prompts'
|
||||
? prompts.filter(p => !p.visibility || p.visibility === 'under_review').length
|
||||
: simulatorRuns.filter(s => !s.visibility || s.visibility === 'under_review').length
|
||||
const publishedCount = activeTab === 'prompts'
|
||||
? prompts.filter(p => p.visibility === 'published').length
|
||||
: simulatorRuns.filter(s => s.visibility === 'published').length
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
@ -79,10 +154,10 @@ export default function AdminReviewPage() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
|
||||
{t('allPrompts')}
|
||||
内容审核
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('reviewPromptsDesc')}
|
||||
审核用户提交的提示词和运行结果
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -92,7 +167,7 @@ export default function AdminReviewPage() {
|
||||
<div className="flex items-center justify-center min-h-96">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p className="text-sm text-muted-foreground">{t('loadingPrompts')}</p>
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -106,37 +181,63 @@ export default function AdminReviewPage() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
|
||||
{t('allPrompts')}
|
||||
内容审核
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('reviewPromptsDesc')}
|
||||
审核用户提交的提示词和运行结果
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs sm:text-sm">
|
||||
{prompts.filter(p => !p.visibility || p.visibility === 'under_review').length} {t('pending')}
|
||||
{pendingCount} 待审核
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||
{prompts.filter(p => p.visibility === 'published').length} {t('published')}
|
||||
{publishedCount} 已发布
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('prompts')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'prompts'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2 inline" />
|
||||
提示词
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('simulators')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'simulators'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2 inline" />
|
||||
运行结果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{prompts.length === 0 ? (
|
||||
{currentItems.length === 0 ? (
|
||||
<Card className="p-8 lg:p-12 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<CheckCircle className="h-12 w-12 lg:h-16 lg:w-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg lg:text-xl font-semibold text-foreground mb-2">
|
||||
{t('noPromptsPending')}
|
||||
暂无待审核内容
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t('allPromptsReviewed')}
|
||||
所有{activeTab === 'prompts' ? '提示词' : '运行结果'}都已审核完毕
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
) : activeTab === 'prompts' ? (
|
||||
<div className="space-y-4 lg:space-y-6">
|
||||
{prompts.map((prompt) => (
|
||||
<Card key={prompt.id} className="p-4 lg:p-6 border-border hover:border-primary/20 transition-all duration-200">
|
||||
@ -162,7 +263,7 @@ export default function AdminReviewPage() {
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{prompt.visibility === 'published' ? t('published') : t('underReview')}
|
||||
{prompt.visibility === 'published' ? '已发布' : '待审核'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@ -186,7 +287,7 @@ export default function AdminReviewPage() {
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('promptContent')}
|
||||
提示词内容
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground max-h-32 lg:max-h-40 overflow-y-auto break-words">
|
||||
@ -206,7 +307,7 @@ export default function AdminReviewPage() {
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
{t('approve')}
|
||||
通过
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@ -216,7 +317,141 @@ export default function AdminReviewPage() {
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
{t('reject')}
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 lg:space-y-6">
|
||||
{simulatorRuns.map((run) => (
|
||||
<Card key={run.id} className="p-4 lg:p-6 border-border hover:border-primary/20 transition-all duration-200">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<h3 className="text-lg lg:text-xl font-semibold text-foreground break-words">
|
||||
{run.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
模型:{run.model.provider} - {run.model.name}
|
||||
{run.model.outputType && run.model.outputType !== 'text' && (
|
||||
<span className="ml-2 text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-0.5 rounded">
|
||||
{run.model.outputType}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Badge
|
||||
variant={run.visibility === 'published' ? 'default' : 'outline'}
|
||||
className={`text-xs sm:text-sm ${
|
||||
run.visibility === 'published'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{run.visibility === 'published' ? '已发布' : '待审核'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{run.user.username || run.user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(run.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt and Input Preview */}
|
||||
<div className="grid gap-4">
|
||||
<div className="border border-border rounded-lg p-3 lg:p-4 bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
提示词:{run.prompt.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground max-h-20 overflow-y-auto break-words">
|
||||
{run.prompt.content.length > 200
|
||||
? `${run.prompt.content.slice(0, 200)}...`
|
||||
: run.prompt.content
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg p-3 lg:p-4 bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
用户输入
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground max-h-20 overflow-y-auto break-words">
|
||||
{run.userInput.length > 200
|
||||
? `${run.userInput.slice(0, 200)}...`
|
||||
: run.userInput
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run.output && (
|
||||
<div className="border border-border rounded-lg p-3 lg:p-4 bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
生成结果
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground max-h-32 lg:max-h-40 overflow-y-auto break-words">
|
||||
{run.model.outputType === 'image' ? (
|
||||
<span className="text-muted-foreground">图片内容(点击查看完整运行结果)</span>
|
||||
) : run.output.length > 300 ? (
|
||||
`${run.output.slice(0, 300)}...`
|
||||
) : run.output}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 pt-2 border-t border-border/50">
|
||||
<Button
|
||||
onClick={() => window.open(`/simulator/${run.id}`, '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</Button>
|
||||
{run.visibility !== 'published' && (
|
||||
<Button
|
||||
onClick={() => handleApproveSimulator(run.id)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white w-full sm:w-auto"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
通过
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleRejectSimulator(run.id)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
65
src/app/api/admin/simulators/[id]/approve/route.ts
Normal file
65
src/app/api/admin/simulators/[id]/approve/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { isAdmin: true }
|
||||
})
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if simulator run exists and is public
|
||||
const simulatorRun = await prisma.simulatorRun.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
permissions: 'public'
|
||||
}
|
||||
})
|
||||
|
||||
if (!simulatorRun) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Simulator run not found or not public' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Approve the simulator run
|
||||
const approvedRun = await prisma.simulatorRun.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
visibility: 'published'
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
simulatorRun: approvedRun
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error approving simulator run:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to approve simulator run' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
66
src/app/api/admin/simulators/[id]/reject/route.ts
Normal file
66
src/app/api/admin/simulators/[id]/reject/route.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { isAdmin: true }
|
||||
})
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if simulator run exists and is public
|
||||
const simulatorRun = await prisma.simulatorRun.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
permissions: 'public'
|
||||
}
|
||||
})
|
||||
|
||||
if (!simulatorRun) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Simulator run not found or not public' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Reject the simulator run by setting it back to private
|
||||
const rejectedRun = await prisma.simulatorRun.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
permissions: 'private',
|
||||
visibility: null
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
simulatorRun: rejectedRun
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error rejecting simulator run:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reject simulator run' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
71
src/app/api/admin/simulators/pending/route.ts
Normal file
71
src/app/api/admin/simulators/pending/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { isAdmin: true }
|
||||
})
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get simulator runs that are public (including pending and published ones for review)
|
||||
const simulatorRuns = await prisma.simulatorRun.findMany({
|
||||
where: {
|
||||
permissions: 'public',
|
||||
OR: [
|
||||
{ visibility: null },
|
||||
{ visibility: 'under_review' },
|
||||
{ visibility: 'published' }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
prompt: {
|
||||
select: {
|
||||
name: true,
|
||||
content: true
|
||||
}
|
||||
},
|
||||
model: {
|
||||
select: {
|
||||
name: true,
|
||||
provider: true,
|
||||
outputType: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ simulatorRuns })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending simulator runs:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch simulator runs' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
66
src/app/api/auth/set-password/route.ts
Normal file
66
src/app/api/auth/set-password/route.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { newPassword } = await request.json();
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 6 characters long" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用 Better Auth 的 setPassword API
|
||||
try {
|
||||
const result = await auth.api.setPassword({
|
||||
body: {
|
||||
newPassword: newPassword
|
||||
},
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
console.log("Better Auth setPassword result:", result);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Password set successfully",
|
||||
data: result
|
||||
});
|
||||
} catch (authError: unknown) {
|
||||
console.error("Better Auth setPassword error:", authError);
|
||||
|
||||
const errorMessage = authError instanceof Error ? authError.message : 'Unknown auth error';
|
||||
|
||||
// 如果是因为用户已有密码,建议使用 changePassword
|
||||
if (errorMessage.includes("already has a password")) {
|
||||
return NextResponse.json(
|
||||
{ error: "User already has a password. Please use the change password functionality." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage || "Failed to set password" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error("Error setting password:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get('search') || ''
|
||||
const tag = searchParams.get('tag') || ''
|
||||
const type = searchParams.get('type') || 'prompts' // 'prompts' | 'simulators' | 'all'
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '12')
|
||||
const sortBy = searchParams.get('sortBy') || 'createdAt'
|
||||
@ -15,97 +16,203 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// 构建where条件
|
||||
const where: Record<string, unknown> = {
|
||||
permissions: 'public',
|
||||
visibility: 'published',
|
||||
}
|
||||
|
||||
// 搜索条件:根据名称和描述匹配
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
// 标签过滤
|
||||
if (tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
name: tag
|
||||
}
|
||||
if (type === 'prompts') {
|
||||
// 获取提示词数据
|
||||
const where: Record<string, unknown> = {
|
||||
permissions: 'public',
|
||||
visibility: 'published',
|
||||
}
|
||||
}
|
||||
|
||||
// 排序条件
|
||||
const orderBy: Record<string, string> = {}
|
||||
if (sortBy === 'name') {
|
||||
orderBy.name = sortOrder
|
||||
} else {
|
||||
orderBy.createdAt = sortOrder
|
||||
}
|
||||
// 搜索条件:根据名称和描述匹配
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
// 获取总数和数据
|
||||
const [total, prompts] = await Promise.all([
|
||||
prisma.prompt.count({ where }),
|
||||
prisma.prompt.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
image: true,
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
}
|
||||
},
|
||||
versions: {
|
||||
orderBy: {
|
||||
version: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
content: true,
|
||||
version: true,
|
||||
createdAt: true,
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
versions: true,
|
||||
}
|
||||
// 标签过滤
|
||||
if (tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
name: tag
|
||||
}
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return NextResponse.json({
|
||||
prompts,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
}
|
||||
})
|
||||
|
||||
// 排序条件
|
||||
const orderBy: Record<string, string> = {}
|
||||
if (sortBy === 'name') {
|
||||
orderBy.name = sortOrder
|
||||
} else {
|
||||
orderBy.createdAt = sortOrder
|
||||
}
|
||||
|
||||
// 获取总数和数据
|
||||
const [total, prompts] = await Promise.all([
|
||||
prisma.prompt.count({ where }),
|
||||
prisma.prompt.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
image: true,
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
}
|
||||
},
|
||||
versions: {
|
||||
orderBy: {
|
||||
version: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
content: true,
|
||||
version: true,
|
||||
createdAt: true,
|
||||
}
|
||||
},
|
||||
stats: {
|
||||
select: {
|
||||
viewCount: true,
|
||||
likeCount: true,
|
||||
rating: true,
|
||||
ratingCount: true,
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
versions: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return NextResponse.json({
|
||||
prompts,
|
||||
type: 'prompts',
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
}
|
||||
})
|
||||
|
||||
} else if (type === 'simulators') {
|
||||
// 获取运行结果数据
|
||||
const where: Record<string, unknown> = {
|
||||
permissions: 'public',
|
||||
visibility: 'published',
|
||||
}
|
||||
|
||||
// 搜索条件
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ userInput: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
// 排序条件
|
||||
const orderBy: Record<string, string> = {}
|
||||
if (sortBy === 'name') {
|
||||
orderBy.name = sortOrder
|
||||
} else {
|
||||
orderBy.createdAt = sortOrder
|
||||
}
|
||||
|
||||
// 获取总数和数据
|
||||
const [total, simulatorRuns] = await Promise.all([
|
||||
prisma.simulatorRun.count({ where }),
|
||||
prisma.simulatorRun.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
image: true,
|
||||
}
|
||||
},
|
||||
prompt: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
content: true,
|
||||
tags: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
model: {
|
||||
select: {
|
||||
name: true,
|
||||
provider: true,
|
||||
outputType: true,
|
||||
description: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return NextResponse.json({
|
||||
simulatorRuns,
|
||||
type: 'simulators',
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 返回混合内容 (暂时返回空,可以后续实现)
|
||||
return NextResponse.json({
|
||||
prompts: [],
|
||||
simulatorRuns: [],
|
||||
type: 'all',
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching plaza prompts:', error)
|
||||
console.error('Error fetching plaza content:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch prompts' },
|
||||
{ error: 'Failed to fetch content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
@ -66,9 +66,9 @@ const IMAGE_MODEL_ADAPTERS: Record<string, ModelAdapter> = {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有图片数据,返回图片数据,否则返回文本内容
|
||||
// 如果有图片数据,不返回到 content 中,只存储到 imageData
|
||||
return {
|
||||
content: imageData || content || 'No image data found in response',
|
||||
content: imageData ? '' : (content || 'No image data found in response'),
|
||||
outputType: 'image',
|
||||
imageData: imageData
|
||||
}
|
||||
@ -130,7 +130,7 @@ export async function POST(
|
||||
// Check user's credit balance before execution
|
||||
const userBalance = await getUserBalance(user.id);
|
||||
const costMultiplier = (run.user.subscriptionPlan as { costMultiplier?: number })?.costMultiplier || 1.0;
|
||||
const estimatedCost = calculateCost(0, 100, run.model, costMultiplier); // Rough estimate
|
||||
const estimatedCost = calculateCost(50, 100, run.model, costMultiplier); // Rough estimate
|
||||
|
||||
if (userBalance < estimatedCost) {
|
||||
return NextResponse.json(
|
||||
|
@ -32,6 +32,8 @@ export async function GET(
|
||||
promptContent: true,
|
||||
output: true,
|
||||
error: true,
|
||||
permissions: true,
|
||||
visibility: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
temperature: true,
|
||||
|
91
src/app/api/simulator/[id]/share/route.ts
Normal file
91
src/app/api/simulator/[id]/share/route.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { permissions } = await request.json()
|
||||
|
||||
if (!permissions || !['private', 'public'].includes(permissions)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid permissions value' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查 simulator run 是否存在且属于当前用户
|
||||
const run = await prisma.simulatorRun.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
if (!run) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Simulator run not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 只有已完成的 run 才能共享
|
||||
if (run.status !== 'completed') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only completed simulator runs can be shared' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 更新权限设置
|
||||
const updatedRun = await prisma.simulatorRun.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
permissions,
|
||||
// 如果设为 public,重置 visibility 为等待审核状态
|
||||
visibility: permissions === 'public' ? null : null
|
||||
},
|
||||
include: {
|
||||
prompt: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
content: true
|
||||
}
|
||||
},
|
||||
model: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
modelId: true,
|
||||
outputType: true,
|
||||
description: true,
|
||||
maxTokens: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json(updatedRun)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling simulator run share status:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update share status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
@ -95,11 +95,9 @@ export async function GET(request: NextRequest) {
|
||||
output: true,
|
||||
error: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
inputTokens: true,
|
||||
outputTokens: true,
|
||||
totalCost: true,
|
||||
duration: true,
|
||||
prompt: {
|
||||
select: { id: true, name: true }
|
||||
},
|
||||
|
@ -67,14 +67,14 @@ export async function POST() {
|
||||
}
|
||||
|
||||
if (isNewUser) {
|
||||
// 为新用户添加系统赠送的5USD信用额度(1个月后过期)
|
||||
// 为新用户添加系统赠送的2USD信用额度(1个月后过期)
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setMonth(expiresAt.getMonth() + 1)
|
||||
|
||||
try {
|
||||
await addCredit(
|
||||
user.id,
|
||||
5.0,
|
||||
2.0,
|
||||
'system_gift',
|
||||
'系统赠送 - 新用户礼包',
|
||||
expiresAt
|
||||
|
@ -98,14 +98,14 @@ export default async function RootLayout({
|
||||
></script>
|
||||
|
||||
{/* Google Analytics */}
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NML19W8SRD"></script>
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-4ZK9RFLTEM"></script>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-NML19W8SRD');
|
||||
gtag('config', 'G-4ZK9RFLTEM');
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
@ -66,13 +66,11 @@ export default function ProfilePage() {
|
||||
username: '',
|
||||
email: '',
|
||||
bio: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
versionLimit: 3
|
||||
})
|
||||
const [showPasswords, setShowPasswords] = useState({
|
||||
current: false,
|
||||
new: false,
|
||||
confirm: false
|
||||
})
|
||||
@ -105,7 +103,6 @@ export default function ProfilePage() {
|
||||
username: profileData.name || '', // 直接使用name字段
|
||||
email: profileData.email,
|
||||
bio: profileData.bio || '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
versionLimit: profileData.versionLimit
|
||||
@ -193,9 +190,40 @@ export default function ProfilePage() {
|
||||
setSaveStatus({ type: null, message: '' })
|
||||
|
||||
try {
|
||||
// TODO: 实现Better Auth密码更新
|
||||
// Better Auth的密码更新需要通过特殊的API端点
|
||||
setSaveStatus({ type: 'error', message: 'Password update is not yet implemented for Better Auth' })
|
||||
// 使用自定义API来设置密码(只需要新密码)
|
||||
const response = await fetch('/api/auth/set-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
newPassword: formData.newPassword
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to set password'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.error || errorMessage
|
||||
} catch {
|
||||
// 如果响应不是JSON格式,使用状态码信息
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
// 尝试解析成功响应
|
||||
try {
|
||||
const result = await response.json()
|
||||
console.log('Password set successfully:', result)
|
||||
} catch {
|
||||
// 即使解析失败,如果状态码是成功的,仍然认为操作成功
|
||||
console.log('Password set successfully (no JSON response)')
|
||||
}
|
||||
|
||||
setSaveStatus({ type: 'success', message: t('passwordUpdatedSuccessfully') })
|
||||
setFormData({ ...formData, newPassword: '', confirmPassword: '' })
|
||||
|
||||
} catch (error: unknown) {
|
||||
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword') })
|
||||
|
@ -29,7 +29,9 @@ import {
|
||||
DollarSign,
|
||||
Edit,
|
||||
Save,
|
||||
X
|
||||
X,
|
||||
Share2,
|
||||
Globe
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
@ -48,6 +50,8 @@ interface SimulatorRun {
|
||||
output?: string
|
||||
error?: string
|
||||
outputType?: string
|
||||
permissions: string
|
||||
visibility?: string | null
|
||||
createdAt: string
|
||||
completedAt?: string
|
||||
temperature?: number
|
||||
@ -108,6 +112,7 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
|
||||
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null)
|
||||
const [isLoadingImage, setIsLoadingImage] = useState(false)
|
||||
const [imageLoadError, setImageLoadError] = useState(false)
|
||||
const [isTogglingShare, setIsTogglingShare] = useState(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
userInput: '',
|
||||
@ -386,6 +391,35 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleShare = async () => {
|
||||
if (!run) return
|
||||
|
||||
setIsTogglingShare(true)
|
||||
try {
|
||||
const newPermissions = run.permissions === 'private' ? 'public' : 'private'
|
||||
const response = await fetch(`/api/simulator/${run.id}/share`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
permissions: newPermissions,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedRun = await response.json()
|
||||
setRun(updatedRun)
|
||||
} else {
|
||||
console.error('Error toggling share status:', await response.text())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling share status:', error)
|
||||
} finally {
|
||||
setIsTogglingShare(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
@ -491,6 +525,18 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
|
||||
<span>{t(`status.${run.status}`)}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
{/* 分享状态显示 */}
|
||||
{run.permissions === 'public' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{run.visibility === 'published' ? (
|
||||
<span className="text-green-600 dark:text-green-400">已发布</span>
|
||||
) : (
|
||||
<span className="text-orange-600 dark:text-orange-400">待审核</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<span>{run.model.provider} {run.model.name}</span>
|
||||
<span>
|
||||
{formatDistanceToNow(new Date(run.createdAt), {
|
||||
@ -502,6 +548,32 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 共享按钮 - 只有已完成的 run 才能共享 */}
|
||||
{run.status === 'completed' && (
|
||||
<Button
|
||||
onClick={handleToggleShare}
|
||||
disabled={isTogglingShare}
|
||||
variant={run.permissions === 'public' ? 'default' : 'outline'}
|
||||
>
|
||||
{isTogglingShare ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="ml-2">{t('updating')}</span>
|
||||
</>
|
||||
) : run.permissions === 'public' ? (
|
||||
<>
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
{t('shared')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
{t('shareRun')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleDuplicateRun}
|
||||
disabled={isDuplicating}
|
||||
|
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
@ -160,7 +160,7 @@ export default function SimulatorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getRunStats = () => {
|
||||
const stats = useMemo(() => {
|
||||
const total = runs.length
|
||||
const completed = runs.filter(run => run.status === 'completed').length
|
||||
const running = runs.filter(run => run.status === 'running').length
|
||||
@ -173,7 +173,7 @@ export default function SimulatorPage() {
|
||||
const totalCost = runs.reduce((sum, run) => sum + (run.totalCost || 0), 0)
|
||||
|
||||
return { total, completed, running, failed, totalTokens, totalCost }
|
||||
}
|
||||
}, [runs])
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
@ -190,7 +190,6 @@ export default function SimulatorPage() {
|
||||
return null
|
||||
}
|
||||
|
||||
const stats = getRunStats()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
|
@ -5,8 +5,9 @@ import { useTranslations } from 'next-intl'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { PlazaFilters } from './PlazaFilters'
|
||||
import { PromptCard } from './PromptCard'
|
||||
import { SimulatorCard } from './SimulatorCard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Loader2, FileText, Play } from 'lucide-react'
|
||||
|
||||
interface Prompt {
|
||||
id: string
|
||||
@ -40,8 +41,40 @@ interface Prompt {
|
||||
}
|
||||
}
|
||||
|
||||
interface SimulatorRun {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
userInput: string
|
||||
output?: string | null
|
||||
createdAt: string
|
||||
user: {
|
||||
id: string
|
||||
username: string | null
|
||||
image: string | null
|
||||
}
|
||||
prompt: {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
tags: Array<{
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}>
|
||||
}
|
||||
model: {
|
||||
name: string
|
||||
provider: string
|
||||
outputType?: string
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PlazaResponse {
|
||||
prompts: Prompt[]
|
||||
prompts?: Prompt[]
|
||||
simulatorRuns?: SimulatorRun[]
|
||||
type: 'prompts' | 'simulators' | 'all'
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
@ -63,7 +96,9 @@ interface Tag {
|
||||
|
||||
export function PlazaClient() {
|
||||
const t = useTranslations('plaza')
|
||||
const [contentType, setContentType] = useState<'prompts' | 'simulators'>('prompts')
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([])
|
||||
const [simulatorRuns, setSimulatorRuns] = useState<SimulatorRun[]>([])
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@ -80,12 +115,13 @@ export function PlazaClient() {
|
||||
hasPrev: false,
|
||||
})
|
||||
|
||||
const fetchPrompts = useCallback(async (page = 1, reset = false) => {
|
||||
const fetchContent = useCallback(async (page = 1, reset = false) => {
|
||||
if (page === 1) setLoading(true)
|
||||
else setLoadingMore(true)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type: contentType,
|
||||
page: page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sortBy,
|
||||
@ -93,7 +129,7 @@ export function PlazaClient() {
|
||||
})
|
||||
|
||||
if (search) params.append('search', search)
|
||||
if (selectedTag) params.append('tag', selectedTag)
|
||||
if (selectedTag && contentType === 'prompts') params.append('tag', selectedTag)
|
||||
|
||||
const response = await fetch(`/api/plaza?${params}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
@ -101,19 +137,29 @@ export function PlazaClient() {
|
||||
const data: PlazaResponse = await response.json()
|
||||
|
||||
if (reset) {
|
||||
setPrompts(data.prompts)
|
||||
if (data.type === 'prompts' && data.prompts) {
|
||||
setPrompts(data.prompts)
|
||||
setSimulatorRuns([])
|
||||
} else if (data.type === 'simulators' && data.simulatorRuns) {
|
||||
setSimulatorRuns(data.simulatorRuns)
|
||||
setPrompts([])
|
||||
}
|
||||
} else {
|
||||
setPrompts(prev => [...prev, ...data.prompts])
|
||||
if (data.type === 'prompts' && data.prompts) {
|
||||
setPrompts(prev => [...prev, ...data.prompts!])
|
||||
} else if (data.type === 'simulators' && data.simulatorRuns) {
|
||||
setSimulatorRuns(prev => [...prev, ...data.simulatorRuns!])
|
||||
}
|
||||
}
|
||||
|
||||
setPagination(data.pagination)
|
||||
} catch (error) {
|
||||
console.error('Error fetching prompts:', error)
|
||||
console.error('Error fetching content:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [search, selectedTag, sortBy, sortOrder, pagination.limit])
|
||||
}, [contentType, search, selectedTag, sortBy, sortOrder, pagination.limit])
|
||||
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
@ -132,12 +178,12 @@ export function PlazaClient() {
|
||||
}, [fetchTags])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrompts(1, true)
|
||||
}, [search, selectedTag, sortBy, sortOrder, fetchPrompts])
|
||||
fetchContent(1, true)
|
||||
}, [contentType, search, selectedTag, sortBy, sortOrder, fetchContent])
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (pagination.hasNext && !loadingMore) {
|
||||
fetchPrompts(pagination.page + 1, false)
|
||||
fetchContent(pagination.page + 1, false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,13 +212,41 @@ export function PlazaClient() {
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">
|
||||
{t('title')}
|
||||
广场
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t('subtitle')}
|
||||
发现并探索社区分享的提示词和运行效果
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content Type Toggle */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex rounded-lg border border-border p-1 bg-muted/50">
|
||||
<button
|
||||
onClick={() => setContentType('prompts')}
|
||||
className={`inline-flex items-center px-4 py-2 text-sm font-medium rounded-md transition-all ${
|
||||
contentType === 'prompts'
|
||||
? 'bg-background text-foreground shadow-sm border border-border'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
提示词
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setContentType('simulators')}
|
||||
className={`inline-flex items-center px-4 py-2 text-sm font-medium rounded-md transition-all ${
|
||||
contentType === 'simulators'
|
||||
? 'bg-background text-foreground shadow-sm border border-border'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
运行效果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<PlazaFilters
|
||||
search={search}
|
||||
@ -190,10 +264,7 @@ export function PlazaClient() {
|
||||
{/* Results */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('showingResults', {
|
||||
current: prompts.length,
|
||||
total: pagination.total
|
||||
})}
|
||||
显示 {contentType === 'prompts' ? prompts.length : simulatorRuns.length} / {pagination.total} 个{contentType === 'prompts' ? '提示词' : '运行结果'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -201,19 +272,28 @@ export function PlazaClient() {
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">{t('loadingPrompts')}</span>
|
||||
<span className="ml-2">加载中...</span>
|
||||
</div>
|
||||
) : prompts.length > 0 ? (
|
||||
) : (contentType === 'prompts' ? prompts.length > 0 : simulatorRuns.length > 0) ? (
|
||||
<>
|
||||
{/* Prompt Grid */}
|
||||
{/* Content Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{prompts.map((prompt) => (
|
||||
<PromptCard
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
onViewIncrement={incrementViewCount}
|
||||
/>
|
||||
))}
|
||||
{contentType === 'prompts'
|
||||
? prompts.map((prompt) => (
|
||||
<PromptCard
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
onViewIncrement={incrementViewCount}
|
||||
/>
|
||||
))
|
||||
: simulatorRuns.map((simulatorRun) => (
|
||||
<SimulatorCard
|
||||
key={simulatorRun.id}
|
||||
simulatorRun={simulatorRun}
|
||||
onViewIncrement={incrementViewCount}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
@ -228,10 +308,10 @@ export function PlazaClient() {
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
{t('loadingPrompts')}
|
||||
加载中...
|
||||
</>
|
||||
) : (
|
||||
t('loadMore')
|
||||
'加载更多'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@ -240,13 +320,13 @@ export function PlazaClient() {
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t('noPromptsFound')}
|
||||
暂无{contentType === 'prompts' ? '提示词' : '运行结果'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('noPromptsMessage')}
|
||||
尝试调整筛选条件或清除筛选器
|
||||
</p>
|
||||
<Button variant="outline" onClick={handleClearFilters}>
|
||||
{t('clearFilters')}
|
||||
清除筛选器
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
235
src/components/plaza/SimulatorCard.tsx
Normal file
235
src/components/plaza/SimulatorCard.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Play,
|
||||
User as UserIcon,
|
||||
Calendar,
|
||||
Eye,
|
||||
FileText,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
Copy
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN, enUS } from 'date-fns/locale'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { Base64Image } from '@/components/ui/Base64Image'
|
||||
|
||||
interface SimulatorRun {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
userInput: string
|
||||
output?: string | null
|
||||
createdAt: string
|
||||
user: {
|
||||
id: string
|
||||
username: string | null
|
||||
image: string | null
|
||||
}
|
||||
prompt: {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
tags: Array<{
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}>
|
||||
}
|
||||
model: {
|
||||
name: string
|
||||
provider: string
|
||||
outputType?: string
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SimulatorCardProps {
|
||||
simulatorRun: SimulatorRun
|
||||
onViewIncrement?: (id: string) => void
|
||||
}
|
||||
|
||||
export function SimulatorCard({ simulatorRun, onViewIncrement }: SimulatorCardProps) {
|
||||
const t = useTranslations('plaza')
|
||||
const locale = useLocale()
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
const handleViewClick = () => {
|
||||
if (onViewIncrement) {
|
||||
onViewIncrement(simulatorRun.id)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是图片输出
|
||||
const isImageOutput = simulatorRun.model.outputType === 'image'
|
||||
const hasImageUrl = simulatorRun.output && (
|
||||
simulatorRun.output.includes('http') &&
|
||||
simulatorRun.output.match(/\.(png|jpg|jpeg|gif|webp)/i)
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="group hover:shadow-lg transition-all duration-300 border-border hover:border-primary/30 overflow-hidden">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground group-hover:text-primary transition-colors line-clamp-2">
|
||||
{simulatorRun.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{simulatorRun.model.provider}</span>
|
||||
<span>-</span>
|
||||
<span>{simulatorRun.model.name}</span>
|
||||
{simulatorRun.model.outputType && simulatorRun.model.outputType !== 'text' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{simulatorRun.model.outputType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags from prompt */}
|
||||
{simulatorRun.prompt.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{simulatorRun.prompt.tags.slice(0, 3).map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="text-xs px-2 py-0.5"
|
||||
style={{
|
||||
backgroundColor: `${tag.color}15`,
|
||||
borderColor: `${tag.color}30`,
|
||||
color: tag.color
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{simulatorRun.prompt.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{simulatorRun.prompt.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt Info */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>基于提示词:{simulatorRun.prompt.name}</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-sm">
|
||||
<div className="text-xs text-muted-foreground mb-1">用户输入:</div>
|
||||
<div className="text-foreground line-clamp-2">
|
||||
{simulatorRun.userInput}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Preview */}
|
||||
{simulatorRun.output && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Zap className="h-4 w-4" />
|
||||
<span>生成结果</span>
|
||||
</div>
|
||||
|
||||
{isImageOutput && hasImageUrl ? (
|
||||
<div className="relative bg-muted/30 rounded-lg p-3 overflow-hidden">
|
||||
{!imageError ? (
|
||||
<div className="relative group cursor-pointer">
|
||||
<Base64Image
|
||||
src={simulatorRun.output}
|
||||
alt="Generated image"
|
||||
className="w-full max-h-48 object-cover rounded-md"
|
||||
onError={() => setImageError(true)}
|
||||
onClick={() => window.open(simulatorRun.output!, '_blank')}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-md flex items-center justify-center">
|
||||
<ExternalLink className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted rounded-md p-6 text-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
图片预览不可用,点击查看详情
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-lg p-3">
|
||||
<div className="text-sm text-foreground line-clamp-3">
|
||||
{isImageOutput ?
|
||||
"🎨 图片已生成,点击查看详情" :
|
||||
simulatorRun.output
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground pt-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span className="truncate max-w-24">
|
||||
{simulatorRun.user.username || simulatorRun.user.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(simulatorRun.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: locale === 'zh' ? zhCN : enUS
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Link href={`/simulator/${simulatorRun.id}`} className="flex-1">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleViewClick}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(simulatorRun.userInput)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -11,5 +11,6 @@ export const {
|
||||
signUp,
|
||||
signOut,
|
||||
useSession,
|
||||
getSession
|
||||
getSession,
|
||||
changePassword
|
||||
} = authClient
|
@ -53,7 +53,7 @@ export function calculateCost(
|
||||
inputCostPer1k?: number | null
|
||||
outputCostPer1k?: number | null
|
||||
},
|
||||
costMultiplier: number = 1.0
|
||||
costMultiplier?: number
|
||||
): number {
|
||||
const inputCostPer1k = model.inputCostPer1k || 0
|
||||
const outputCostPer1k = model.outputCostPer1k || 0
|
||||
@ -63,8 +63,13 @@ export function calculateCost(
|
||||
const outputCost = (outputTokens / 1000) * outputCostPer1k
|
||||
const baseCost = inputCost + outputCost
|
||||
|
||||
// 应用套餐费用倍率
|
||||
return baseCost * costMultiplier
|
||||
// 获取环境变量中的费用倍率,默认为 10 倍
|
||||
const defaultMultiplier = parseFloat(process.env.FEE_CALCULATION_MULTIPLIER || '10.0')
|
||||
// const finalMultiplier = costMultiplier ?? defaultMultiplier
|
||||
const finalMultiplier = defaultMultiplier
|
||||
|
||||
// 应用费用倍率
|
||||
return baseCost * finalMultiplier
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user