add simulator share
This commit is contained in:
parent
0a067adad2
commit
2b04ef3636
@ -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": "出现错误,请重试。",
|
||||
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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':
|
||||
@ -502,6 +536,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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -53,7 +53,7 @@ export function calculateCost(
|
||||
inputCostPer1k?: number | null
|
||||
outputCostPer1k?: number | null
|
||||
},
|
||||
costMultiplier?: number
|
||||
// costMultiplier?: number
|
||||
): number {
|
||||
const inputCostPer1k = model.inputCostPer1k || 0
|
||||
const outputCostPer1k = model.outputCostPer1k || 0
|
||||
@ -65,7 +65,8 @@ export function calculateCost(
|
||||
|
||||
// 获取环境变量中的费用倍率,默认为 10 倍
|
||||
const defaultMultiplier = parseFloat(process.env.FEE_CALCULATION_MULTIPLIER || '10.0')
|
||||
const finalMultiplier = costMultiplier ?? defaultMultiplier
|
||||
// const finalMultiplier = costMultiplier ?? defaultMultiplier
|
||||
const finalMultiplier = defaultMultiplier
|
||||
|
||||
// 应用费用倍率
|
||||
return baseCost * finalMultiplier
|
||||
|
Loading…
Reference in New Issue
Block a user