add simulator share
This commit is contained in:
parent
0a067adad2
commit
2b04ef3636
@ -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.",
|
||||||
|
@ -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": "出现错误,请重试。",
|
||||||
|
@ -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>
|
||||||
|
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 { 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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
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,
|
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}
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
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
|
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
|
||||||
|
Loading…
Reference in New Issue
Block a user