Compare commits
10 Commits
5c569056e2
...
6bf9d67091
Author | SHA1 | Date | |
---|---|---|---|
6bf9d67091 | |||
779f50ea87 | |||
f3e7fb913d | |||
2b04ef3636 | |||
0a067adad2 | |||
9f2434f182 | |||
21bf7add36 | |||
fb2fb08eae | |||
9b6b5cdda9 | |||
c2417c31da |
@ -193,12 +193,21 @@ FAL_API_KEY="fal-1234567890abcdefghijklmnopqrstuvwxyz"
|
|||||||
| `NEXTAUTH_URL` | NextAuth URL (same as app URL) | `https://prmbr.com` |
|
| `NEXTAUTH_URL` | NextAuth URL (same as app URL) | `https://prmbr.com` |
|
||||||
| `NEXTAUTH_SECRET` | NextAuth secret for JWT signing | Random 32-character string |
|
| `NEXTAUTH_SECRET` | NextAuth secret for JWT signing | Random 32-character string |
|
||||||
|
|
||||||
|
### Optional Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default | Example |
|
||||||
|
|----------|-------------|---------|---------|
|
||||||
|
| `FEE_CALCULATION_MULTIPLIER` | Fee calculation multiplier for AI model costs | `10.0` | `5.0` |
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
```bash
|
```bash
|
||||||
# Application Settings
|
# Application Settings
|
||||||
NEXT_PUBLIC_APP_URL="https://prmbr.com"
|
NEXT_PUBLIC_APP_URL="https://prmbr.com"
|
||||||
NEXTAUTH_URL="https://prmbr.com"
|
NEXTAUTH_URL="https://prmbr.com"
|
||||||
NEXTAUTH_SECRET="your-super-secret-nextauth-secret-32chars"
|
NEXTAUTH_SECRET="your-super-secret-nextauth-secret-32chars"
|
||||||
|
|
||||||
|
# Fee Calculation (Optional - defaults to 10x)
|
||||||
|
FEE_CALCULATION_MULTIPLIER="10.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generating NEXTAUTH_SECRET
|
### Generating NEXTAUTH_SECRET
|
||||||
@ -269,6 +278,9 @@ FAL_API_KEY="fal-1234567890abcdefghijklmnopqrstuvwxyzABCDEF"
|
|||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="super-secret-nextauth-development-key-32-chars-long"
|
NEXTAUTH_SECRET="super-secret-nextauth-development-key-32-chars-long"
|
||||||
|
|
||||||
|
# Fee Calculation (Optional - defaults to 10x)
|
||||||
|
FEE_CALCULATION_MULTIPLIER="10.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🌐 Full Production Configuration
|
### 🌐 Full Production Configuration
|
||||||
@ -311,6 +323,9 @@ FAL_API_KEY="fal-production-1234567890abcdefghijklmnopqrstuvwxyz"
|
|||||||
NEXT_PUBLIC_APP_URL="https://prmbr.com"
|
NEXT_PUBLIC_APP_URL="https://prmbr.com"
|
||||||
NEXTAUTH_URL="https://prmbr.com"
|
NEXTAUTH_URL="https://prmbr.com"
|
||||||
NEXTAUTH_SECRET="super-secret-production-nextauth-key-32-chars-long-secure"
|
NEXTAUTH_SECRET="super-secret-production-nextauth-key-32-chars-long-secure"
|
||||||
|
|
||||||
|
# Fee Calculation (Optional - defaults to 10x)
|
||||||
|
FEE_CALCULATION_MULTIPLIER="10.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔒 Security Best Practices
|
## 🔒 Security Best Practices
|
||||||
|
@ -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": "出现错误,请重试。",
|
||||||
|
@ -259,6 +259,8 @@ model SimulatorRun {
|
|||||||
output String? // AI响应输出
|
output String? // AI响应输出
|
||||||
error String? // 错误信息
|
error String? // 错误信息
|
||||||
status String @default("pending") // "pending", "running", "completed", "failed"
|
status String @default("pending") // "pending", "running", "completed", "failed"
|
||||||
|
permissions String @default("private") // "private" | "public"
|
||||||
|
visibility String? // "under_review" | "published" | null
|
||||||
// 运行配置
|
// 运行配置
|
||||||
temperature Float? @default(0.7)
|
temperature Float? @default(0.7)
|
||||||
maxTokens Int?
|
maxTokens Int?
|
||||||
@ -284,6 +286,9 @@ model SimulatorRun {
|
|||||||
promptVersion PromptVersion? @relation(fields: [promptVersionId], references: [id], onDelete: SetNull)
|
promptVersion PromptVersion? @relation(fields: [promptVersionId], references: [id], onDelete: SetNull)
|
||||||
model Model @relation(fields: [modelId], references: [id])
|
model Model @relation(fields: [modelId], references: [id])
|
||||||
|
|
||||||
|
// 添加索引优化查询性能
|
||||||
|
@@index([userId, createdAt(sort: Desc)])
|
||||||
|
@@index([userId, status, createdAt(sort: Desc)])
|
||||||
@@map("simulator_runs")
|
@@map("simulator_runs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
66
src/app/api/auth/set-password/route.ts
Normal file
66
src/app/api/auth/set-password/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newPassword } = await request.json();
|
||||||
|
|
||||||
|
if (!newPassword || newPassword.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password must be at least 6 characters long" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Better Auth 的 setPassword API
|
||||||
|
try {
|
||||||
|
const result = await auth.api.setPassword({
|
||||||
|
body: {
|
||||||
|
newPassword: newPassword
|
||||||
|
},
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Better Auth setPassword result:", result);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Password set successfully",
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (authError: unknown) {
|
||||||
|
console.error("Better Auth setPassword error:", authError);
|
||||||
|
|
||||||
|
const errorMessage = authError instanceof Error ? authError.message : 'Unknown auth error';
|
||||||
|
|
||||||
|
// 如果是因为用户已有密码,建议使用 changePassword
|
||||||
|
if (errorMessage.includes("already has a password")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User already has a password. Please use the change password functionality." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorMessage || "Failed to set password" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Error setting password:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url)
|
const { 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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -66,9 +66,9 @@ const IMAGE_MODEL_ADAPTERS: Record<string, ModelAdapter> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有图片数据,返回图片数据,否则返回文本内容
|
// 如果有图片数据,不返回到 content 中,只存储到 imageData
|
||||||
return {
|
return {
|
||||||
content: imageData || content || 'No image data found in response',
|
content: imageData ? '' : (content || 'No image data found in response'),
|
||||||
outputType: 'image',
|
outputType: 'image',
|
||||||
imageData: imageData
|
imageData: imageData
|
||||||
}
|
}
|
||||||
@ -130,7 +130,7 @@ export async function POST(
|
|||||||
// Check user's credit balance before execution
|
// Check user's credit balance before execution
|
||||||
const userBalance = await getUserBalance(user.id);
|
const userBalance = await getUserBalance(user.id);
|
||||||
const costMultiplier = (run.user.subscriptionPlan as { costMultiplier?: number })?.costMultiplier || 1.0;
|
const costMultiplier = (run.user.subscriptionPlan as { costMultiplier?: number })?.costMultiplier || 1.0;
|
||||||
const estimatedCost = calculateCost(0, 100, run.model, costMultiplier); // Rough estimate
|
const estimatedCost = calculateCost(50, 100, run.model, costMultiplier); // Rough estimate
|
||||||
|
|
||||||
if (userBalance < estimatedCost) {
|
if (userBalance < estimatedCost) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -95,11 +95,9 @@ export async function GET(request: NextRequest) {
|
|||||||
output: true,
|
output: true,
|
||||||
error: true,
|
error: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
completedAt: true,
|
|
||||||
inputTokens: true,
|
inputTokens: true,
|
||||||
outputTokens: true,
|
outputTokens: true,
|
||||||
totalCost: true,
|
totalCost: true,
|
||||||
duration: true,
|
|
||||||
prompt: {
|
prompt: {
|
||||||
select: { id: true, name: true }
|
select: { id: true, name: true }
|
||||||
},
|
},
|
||||||
|
@ -67,14 +67,14 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isNewUser) {
|
if (isNewUser) {
|
||||||
// 为新用户添加系统赠送的5USD信用额度(1个月后过期)
|
// 为新用户添加系统赠送的2USD信用额度(1个月后过期)
|
||||||
const expiresAt = new Date()
|
const expiresAt = new Date()
|
||||||
expiresAt.setMonth(expiresAt.getMonth() + 1)
|
expiresAt.setMonth(expiresAt.getMonth() + 1)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addCredit(
|
await addCredit(
|
||||||
user.id,
|
user.id,
|
||||||
5.0,
|
2.0,
|
||||||
'system_gift',
|
'system_gift',
|
||||||
'系统赠送 - 新用户礼包',
|
'系统赠送 - 新用户礼包',
|
||||||
expiresAt
|
expiresAt
|
||||||
|
@ -98,14 +98,14 @@ export default async function RootLayout({
|
|||||||
></script>
|
></script>
|
||||||
|
|
||||||
{/* Google Analytics */}
|
{/* Google Analytics */}
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NML19W8SRD"></script>
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-4ZK9RFLTEM"></script>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag(){dataLayer.push(arguments);}
|
function gtag(){dataLayer.push(arguments);}
|
||||||
gtag('js', new Date());
|
gtag('js', new Date());
|
||||||
gtag('config', 'G-NML19W8SRD');
|
gtag('config', 'G-4ZK9RFLTEM');
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -66,13 +66,11 @@ export default function ProfilePage() {
|
|||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
bio: '',
|
bio: '',
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
versionLimit: 3
|
versionLimit: 3
|
||||||
})
|
})
|
||||||
const [showPasswords, setShowPasswords] = useState({
|
const [showPasswords, setShowPasswords] = useState({
|
||||||
current: false,
|
|
||||||
new: false,
|
new: false,
|
||||||
confirm: false
|
confirm: false
|
||||||
})
|
})
|
||||||
@ -105,7 +103,6 @@ export default function ProfilePage() {
|
|||||||
username: profileData.name || '', // 直接使用name字段
|
username: profileData.name || '', // 直接使用name字段
|
||||||
email: profileData.email,
|
email: profileData.email,
|
||||||
bio: profileData.bio || '',
|
bio: profileData.bio || '',
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
versionLimit: profileData.versionLimit
|
versionLimit: profileData.versionLimit
|
||||||
@ -193,9 +190,40 @@ export default function ProfilePage() {
|
|||||||
setSaveStatus({ type: null, message: '' })
|
setSaveStatus({ type: null, message: '' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 实现Better Auth密码更新
|
// 使用自定义API来设置密码(只需要新密码)
|
||||||
// Better Auth的密码更新需要通过特殊的API端点
|
const response = await fetch('/api/auth/set-password', {
|
||||||
setSaveStatus({ type: 'error', message: 'Password update is not yet implemented for Better Auth' })
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
newPassword: formData.newPassword
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = 'Failed to set password'
|
||||||
|
try {
|
||||||
|
const errorData = await response.json()
|
||||||
|
errorMessage = errorData.error || errorMessage
|
||||||
|
} catch {
|
||||||
|
// 如果响应不是JSON格式,使用状态码信息
|
||||||
|
errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析成功响应
|
||||||
|
try {
|
||||||
|
const result = await response.json()
|
||||||
|
console.log('Password set successfully:', result)
|
||||||
|
} catch {
|
||||||
|
// 即使解析失败,如果状态码是成功的,仍然认为操作成功
|
||||||
|
console.log('Password set successfully (no JSON response)')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus({ type: 'success', message: t('passwordUpdatedSuccessfully') })
|
||||||
|
setFormData({ ...formData, newPassword: '', confirmPassword: '' })
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword') })
|
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword') })
|
||||||
|
@ -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':
|
||||||
@ -491,6 +525,18 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
|
|||||||
<span>{t(`status.${run.status}`)}</span>
|
<span>{t(`status.${run.status}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
|
{/* 分享状态显示 */}
|
||||||
|
{run.permissions === 'public' && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{run.visibility === 'published' ? (
|
||||||
|
<span className="text-green-600 dark:text-green-400">已发布</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">待审核</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
<span>{run.model.provider} {run.model.name}</span>
|
<span>{run.model.provider} {run.model.name}</span>
|
||||||
<span>
|
<span>
|
||||||
{formatDistanceToNow(new Date(run.createdAt), {
|
{formatDistanceToNow(new Date(run.createdAt), {
|
||||||
@ -502,6 +548,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}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
@ -160,7 +160,7 @@ export default function SimulatorPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRunStats = () => {
|
const stats = useMemo(() => {
|
||||||
const total = runs.length
|
const total = runs.length
|
||||||
const completed = runs.filter(run => run.status === 'completed').length
|
const completed = runs.filter(run => run.status === 'completed').length
|
||||||
const running = runs.filter(run => run.status === 'running').length
|
const running = runs.filter(run => run.status === 'running').length
|
||||||
@ -173,7 +173,7 @@ export default function SimulatorPage() {
|
|||||||
const totalCost = runs.reduce((sum, run) => sum + (run.totalCost || 0), 0)
|
const totalCost = runs.reduce((sum, run) => sum + (run.totalCost || 0), 0)
|
||||||
|
|
||||||
return { total, completed, running, failed, totalTokens, totalCost }
|
return { total, completed, running, failed, totalTokens, totalCost }
|
||||||
}
|
}, [runs])
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
@ -190,7 +190,6 @@ export default function SimulatorPage() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = getRunStats()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -11,5 +11,6 @@ export const {
|
|||||||
signUp,
|
signUp,
|
||||||
signOut,
|
signOut,
|
||||||
useSession,
|
useSession,
|
||||||
getSession
|
getSession,
|
||||||
|
changePassword
|
||||||
} = authClient
|
} = authClient
|
@ -53,7 +53,7 @@ export function calculateCost(
|
|||||||
inputCostPer1k?: number | null
|
inputCostPer1k?: number | null
|
||||||
outputCostPer1k?: number | null
|
outputCostPer1k?: number | null
|
||||||
},
|
},
|
||||||
costMultiplier: number = 1.0
|
costMultiplier?: number
|
||||||
): number {
|
): number {
|
||||||
const inputCostPer1k = model.inputCostPer1k || 0
|
const inputCostPer1k = model.inputCostPer1k || 0
|
||||||
const outputCostPer1k = model.outputCostPer1k || 0
|
const outputCostPer1k = model.outputCostPer1k || 0
|
||||||
@ -63,8 +63,13 @@ export function calculateCost(
|
|||||||
const outputCost = (outputTokens / 1000) * outputCostPer1k
|
const outputCost = (outputTokens / 1000) * outputCostPer1k
|
||||||
const baseCost = inputCost + outputCost
|
const baseCost = inputCost + outputCost
|
||||||
|
|
||||||
// 应用套餐费用倍率
|
// 获取环境变量中的费用倍率,默认为 10 倍
|
||||||
return baseCost * costMultiplier
|
const defaultMultiplier = parseFloat(process.env.FEE_CALCULATION_MULTIPLIER || '10.0')
|
||||||
|
// const finalMultiplier = costMultiplier ?? defaultMultiplier
|
||||||
|
const finalMultiplier = defaultMultiplier
|
||||||
|
|
||||||
|
// 应用费用倍率
|
||||||
|
return baseCost * finalMultiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user