add simulator share

This commit is contained in:
songtianlun 2025-09-02 00:11:45 +08:00
parent 0a067adad2
commit 2b04ef3636
13 changed files with 1162 additions and 143 deletions

View File

@ -366,8 +366,8 @@
"planModelsCount": "models available" "planModelsCount": "models available"
}, },
"plaza": { "plaza": {
"title": "Prompt Plaza", "title": "Plaza",
"subtitle": "Discover and explore prompts shared by the community", "subtitle": "Discover and explore shared prompts and simulation results",
"searchPlaceholder": "Search prompts by name or description...", "searchPlaceholder": "Search prompts by name or description...",
"filterByTag": "Filter by tag", "filterByTag": "Filter by tag",
"allTags": "All Tags", "allTags": "All Tags",
@ -472,7 +472,10 @@
"running": "Running", "running": "Running",
"completed": "Completed", "completed": "Completed",
"failed": "Failed" "failed": "Failed"
} },
"shareRun": "Share Run",
"shared": "Shared",
"updating": "Updating..."
}, },
"errors": { "errors": {
"generic": "Something went wrong. Please try again.", "generic": "Something went wrong. Please try again.",

View File

@ -367,8 +367,8 @@
"planModelsCount": "个可用模型" "planModelsCount": "个可用模型"
}, },
"plaza": { "plaza": {
"title": "提示词广场", "title": "广场",
"subtitle": "发现并探索社区分享的提示词", "subtitle": "发现并探索社区分享的提示词和运行效果",
"searchPlaceholder": "按名称或描述搜索提示词...", "searchPlaceholder": "按名称或描述搜索提示词...",
"filterByTag": "按标签筛选", "filterByTag": "按标签筛选",
"allTags": "所有标签", "allTags": "所有标签",
@ -472,7 +472,10 @@
"running": "运行中", "running": "运行中",
"completed": "已完成", "completed": "已完成",
"failed": "失败" "failed": "失败"
} },
"shareRun": "分享运行",
"shared": "已分享",
"updating": "更新中..."
}, },
"errors": { "errors": {
"generic": "出现错误,请重试。", "generic": "出现错误,请重试。",

View File

@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' 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' import { formatDistanceToNow } from 'date-fns'
interface ReviewPrompt { 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() { export default function AdminReviewPage() {
const t = useTranslations('admin') const t = useTranslations('admin')
const [activeTab, setActiveTab] = useState<'prompts' | 'simulators'>('prompts')
const [prompts, setPrompts] = useState<ReviewPrompt[]>([]) const [prompts, setPrompts] = useState<ReviewPrompt[]>([])
const [simulatorRuns, setSimulatorRuns] = useState<ReviewSimulatorRun[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const fetchPendingPrompts = async () => { const fetchPendingPrompts = async () => {
@ -36,13 +61,29 @@ export default function AdminReviewPage() {
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch pending prompts:', 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(() => { useEffect(() => {
fetchPendingPrompts() fetchData()
}, []) }, [])
const handleApprove = async (promptId: string) => { 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) { if (loading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 py-8"> <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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2"> <h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
{t('allPrompts')}
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('reviewPromptsDesc')}
</p> </p>
</div> </div>
</div> </div>
@ -92,7 +167,7 @@ export default function AdminReviewPage() {
<div className="flex items-center justify-center min-h-96"> <div className="flex items-center justify-center min-h-96">
<div className="flex flex-col items-center gap-3"> <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> <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> </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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2"> <h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
{t('allPrompts')}
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('reviewPromptsDesc')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs sm:text-sm"> <Badge variant="secondary" className="text-xs sm:text-sm">
{prompts.filter(p => !p.visibility || p.visibility === 'under_review').length} {t('pending')} {pendingCount}
</Badge> </Badge>
<Badge variant="outline" className="text-xs sm:text-sm"> <Badge variant="outline" className="text-xs sm:text-sm">
{prompts.filter(p => p.visibility === 'published').length} {t('published')} {publishedCount}
</Badge> </Badge>
</div> </div>
</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> </div>
{/* Content */} {/* Content */}
{prompts.length === 0 ? ( {currentItems.length === 0 ? (
<Card className="p-8 lg:p-12 text-center"> <Card className="p-8 lg:p-12 text-center">
<div className="max-w-md mx-auto"> <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" /> <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"> <h3 className="text-lg lg:text-xl font-semibold text-foreground mb-2">
{t('noPromptsPending')}
</h3> </h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('allPromptsReviewed')} {activeTab === 'prompts' ? '提示词' : '运行结果'}
</p> </p>
</div> </div>
</Card> </Card>
) : ( ) : activeTab === 'prompts' ? (
<div className="space-y-4 lg:space-y-6"> <div className="space-y-4 lg:space-y-6">
{prompts.map((prompt) => ( {prompts.map((prompt) => (
<Card key={prompt.id} className="p-4 lg:p-6 border-border hover:border-primary/20 transition-all duration-200"> <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> </Badge>
</div> </div>
</div> </div>
@ -186,7 +287,7 @@ export default function AdminReviewPage() {
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Eye className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Eye className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{t('promptContent')}
</span> </span>
</div> </div>
<div className="text-sm text-foreground max-h-32 lg:max-h-40 overflow-y-auto break-words"> <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" size="sm"
> >
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
{t('approve')}
</Button> </Button>
)} )}
<Button <Button
@ -216,7 +317,141 @@ export default function AdminReviewPage() {
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<XCircle className="h-4 w-4 mr-2" /> <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> </Button>
</div> </div>
</div> </div>

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const search = searchParams.get('search') || '' const search = searchParams.get('search') || ''
const tag = searchParams.get('tag') || '' const tag = searchParams.get('tag') || ''
const type = searchParams.get('type') || 'prompts' // 'prompts' | 'simulators' | 'all'
const page = parseInt(searchParams.get('page') || '1') const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '12') const limit = parseInt(searchParams.get('limit') || '12')
const sortBy = searchParams.get('sortBy') || 'createdAt' const sortBy = searchParams.get('sortBy') || 'createdAt'
@ -15,97 +16,203 @@ export async function GET(request: NextRequest) {
const skip = (page - 1) * limit const skip = (page - 1) * limit
// 构建where条件 if (type === 'prompts') {
const where: Record<string, unknown> = { // 获取提示词数据
permissions: 'public', const where: Record<string, unknown> = {
visibility: 'published', 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
}
} }
}
// 排序条件 // 搜索条件:根据名称和描述匹配
const orderBy: Record<string, string> = {} if (search) {
if (sortBy === 'name') { where.OR = [
orderBy.name = sortOrder { name: { contains: search, mode: 'insensitive' } },
} else { { description: { contains: search, mode: 'insensitive' } },
orderBy.createdAt = sortOrder ]
} }
// 获取总数和数据 // 标签过滤
const [total, prompts] = await Promise.all([ if (tag) {
prisma.prompt.count({ where }), where.tags = {
prisma.prompt.findMany({ some: {
where, name: tag
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,
}
} }
} }
})
])
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) { } catch (error) {
console.error('Error fetching plaza prompts:', error) console.error('Error fetching plaza content:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch prompts' }, { error: 'Failed to fetch content' },
{ status: 500 } { status: 500 }
) )
} }

View File

@ -32,6 +32,8 @@ export async function GET(
promptContent: true, promptContent: true,
output: true, output: true,
error: true, error: true,
permissions: true,
visibility: true,
createdAt: true, createdAt: true,
completedAt: true, completedAt: true,
temperature: true, temperature: true,

View 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 }
)
}
}

View File

@ -29,7 +29,9 @@ import {
DollarSign, DollarSign,
Edit, Edit,
Save, Save,
X X,
Share2,
Globe
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
@ -48,6 +50,8 @@ interface SimulatorRun {
output?: string output?: string
error?: string error?: string
outputType?: string outputType?: string
permissions: string
visibility?: string | null
createdAt: string createdAt: string
completedAt?: string completedAt?: string
temperature?: number temperature?: number
@ -108,6 +112,7 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null) const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null)
const [isLoadingImage, setIsLoadingImage] = useState(false) const [isLoadingImage, setIsLoadingImage] = useState(false)
const [imageLoadError, setImageLoadError] = useState(false) const [imageLoadError, setImageLoadError] = useState(false)
const [isTogglingShare, setIsTogglingShare] = useState(false)
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
name: '', name: '',
userInput: '', 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) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case 'pending': case 'pending':
@ -502,6 +536,32 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
</div> </div>
<div className="flex items-center space-x-2"> <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 <Button
onClick={handleDuplicateRun} onClick={handleDuplicateRun}
disabled={isDuplicating} disabled={isDuplicating}

View File

@ -5,8 +5,9 @@ import { useTranslations } from 'next-intl'
import { Header } from '@/components/layout/Header' import { Header } from '@/components/layout/Header'
import { PlazaFilters } from './PlazaFilters' import { PlazaFilters } from './PlazaFilters'
import { PromptCard } from './PromptCard' import { PromptCard } from './PromptCard'
import { SimulatorCard } from './SimulatorCard'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react' import { Loader2, FileText, Play } from 'lucide-react'
interface Prompt { interface Prompt {
id: string 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 { interface PlazaResponse {
prompts: Prompt[] prompts?: Prompt[]
simulatorRuns?: SimulatorRun[]
type: 'prompts' | 'simulators' | 'all'
pagination: { pagination: {
page: number page: number
limit: number limit: number
@ -63,7 +96,9 @@ interface Tag {
export function PlazaClient() { export function PlazaClient() {
const t = useTranslations('plaza') const t = useTranslations('plaza')
const [contentType, setContentType] = useState<'prompts' | 'simulators'>('prompts')
const [prompts, setPrompts] = useState<Prompt[]>([]) const [prompts, setPrompts] = useState<Prompt[]>([])
const [simulatorRuns, setSimulatorRuns] = useState<SimulatorRun[]>([])
const [tags, setTags] = useState<Tag[]>([]) const [tags, setTags] = useState<Tag[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
@ -80,12 +115,13 @@ export function PlazaClient() {
hasPrev: false, hasPrev: false,
}) })
const fetchPrompts = useCallback(async (page = 1, reset = false) => { const fetchContent = useCallback(async (page = 1, reset = false) => {
if (page === 1) setLoading(true) if (page === 1) setLoading(true)
else setLoadingMore(true) else setLoadingMore(true)
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
type: contentType,
page: page.toString(), page: page.toString(),
limit: pagination.limit.toString(), limit: pagination.limit.toString(),
sortBy, sortBy,
@ -93,7 +129,7 @@ export function PlazaClient() {
}) })
if (search) params.append('search', search) 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}`) const response = await fetch(`/api/plaza?${params}`)
if (!response.ok) throw new Error('Failed to fetch') if (!response.ok) throw new Error('Failed to fetch')
@ -101,19 +137,29 @@ export function PlazaClient() {
const data: PlazaResponse = await response.json() const data: PlazaResponse = await response.json()
if (reset) { 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 { } 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) setPagination(data.pagination)
} catch (error) { } catch (error) {
console.error('Error fetching prompts:', error) console.error('Error fetching content:', error)
} finally { } finally {
setLoading(false) setLoading(false)
setLoadingMore(false) setLoadingMore(false)
} }
}, [search, selectedTag, sortBy, sortOrder, pagination.limit]) }, [contentType, search, selectedTag, sortBy, sortOrder, pagination.limit])
const fetchTags = useCallback(async () => { const fetchTags = useCallback(async () => {
try { try {
@ -132,12 +178,12 @@ export function PlazaClient() {
}, [fetchTags]) }, [fetchTags])
useEffect(() => { useEffect(() => {
fetchPrompts(1, true) fetchContent(1, true)
}, [search, selectedTag, sortBy, sortOrder, fetchPrompts]) }, [contentType, search, selectedTag, sortBy, sortOrder, fetchContent])
const handleLoadMore = () => { const handleLoadMore = () => {
if (pagination.hasNext && !loadingMore) { if (pagination.hasNext && !loadingMore) {
fetchPrompts(pagination.page + 1, false) fetchContent(pagination.page + 1, false)
} }
} }
@ -166,13 +212,41 @@ export function PlazaClient() {
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-foreground mb-4"> <h1 className="text-4xl font-bold text-foreground mb-4">
{t('title')} 广
</h1> </h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto"> <p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{t('subtitle')}
</p> </p>
</div> </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 */} {/* Filters */}
<PlazaFilters <PlazaFilters
search={search} search={search}
@ -190,10 +264,7 @@ export function PlazaClient() {
{/* Results */} {/* Results */}
<div className="mb-6"> <div className="mb-6">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('showingResults', { {contentType === 'prompts' ? prompts.length : simulatorRuns.length} / {pagination.total} {contentType === 'prompts' ? '提示词' : '运行结果'}
current: prompts.length,
total: pagination.total
})}
</p> </p>
</div> </div>
@ -201,19 +272,28 @@ export function PlazaClient() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" /> <Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('loadingPrompts')}</span> <span className="ml-2">...</span>
</div> </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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{prompts.map((prompt) => ( {contentType === 'prompts'
<PromptCard ? prompts.map((prompt) => (
key={prompt.id} <PromptCard
prompt={prompt} key={prompt.id}
onViewIncrement={incrementViewCount} prompt={prompt}
/> onViewIncrement={incrementViewCount}
))} />
))
: simulatorRuns.map((simulatorRun) => (
<SimulatorCard
key={simulatorRun.id}
simulatorRun={simulatorRun}
onViewIncrement={incrementViewCount}
/>
))
}
</div> </div>
{/* Load More */} {/* Load More */}
@ -228,10 +308,10 @@ export function PlazaClient() {
{loadingMore ? ( {loadingMore ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />
{t('loadingPrompts')} ...
</> </>
) : ( ) : (
t('loadMore') '加载更多'
)} )}
</Button> </Button>
</div> </div>
@ -240,13 +320,13 @@ export function PlazaClient() {
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<h3 className="text-lg font-medium text-foreground mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
{t('noPromptsFound')} {contentType === 'prompts' ? '提示词' : '运行结果'}
</h3> </h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{t('noPromptsMessage')}
</p> </p>
<Button variant="outline" onClick={handleClearFilters}> <Button variant="outline" onClick={handleClearFilters}>
{t('clearFilters')}
</Button> </Button>
</div> </div>
)} )}

View 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>
)
}

View File

@ -53,7 +53,7 @@ export function calculateCost(
inputCostPer1k?: number | null inputCostPer1k?: number | null
outputCostPer1k?: number | null outputCostPer1k?: number | null
}, },
costMultiplier?: number // costMultiplier?: number
): number { ): number {
const inputCostPer1k = model.inputCostPer1k || 0 const inputCostPer1k = model.inputCostPer1k || 0
const outputCostPer1k = model.outputCostPer1k || 0 const outputCostPer1k = model.outputCostPer1k || 0
@ -65,7 +65,8 @@ export function calculateCost(
// 获取环境变量中的费用倍率,默认为 10 倍 // 获取环境变量中的费用倍率,默认为 10 倍
const defaultMultiplier = parseFloat(process.env.FEE_CALCULATION_MULTIPLIER || '10.0') const defaultMultiplier = parseFloat(process.env.FEE_CALCULATION_MULTIPLIER || '10.0')
const finalMultiplier = costMultiplier ?? defaultMultiplier // const finalMultiplier = costMultiplier ?? defaultMultiplier
const finalMultiplier = defaultMultiplier
// 应用费用倍率 // 应用费用倍率
return baseCost * finalMultiplier return baseCost * finalMultiplier