Compare commits

...

10 Commits

Author SHA1 Message Date
6bf9d67091 change gtag id 2025-09-02 16:27:13 +08:00
779f50ea87 fix error 2025-09-02 00:18:05 +08:00
f3e7fb913d fix share sim 2025-09-02 00:15:51 +08:00
2b04ef3636 add simulator share 2025-09-02 00:11:45 +08:00
0a067adad2 add simulator permissions and visibility 2025-09-01 23:54:36 +08:00
9f2434f182 imageData not to content 2025-09-01 23:48:16 +08:00
21bf7add36 fix set password 2025-09-01 23:39:32 +08:00
fb2fb08eae quick to query 2025-09-01 23:21:08 +08:00
9b6b5cdda9 fix passwd change 2025-09-01 23:13:11 +08:00
c2417c31da add multiplier, send 2 usd in newer 2025-09-01 16:38:17 +08:00
23 changed files with 1311 additions and 164 deletions

View File

@ -193,12 +193,21 @@ FAL_API_KEY="fal-1234567890abcdefghijklmnopqrstuvwxyz"
| `NEXTAUTH_URL` | NextAuth URL (same as app URL) | `https://prmbr.com` |
| `NEXTAUTH_SECRET` | NextAuth secret for JWT signing | Random 32-character string |
### Optional Variables
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `FEE_CALCULATION_MULTIPLIER` | Fee calculation multiplier for AI model costs | `10.0` | `5.0` |
### Configuration
```bash
# Application Settings
NEXT_PUBLIC_APP_URL="https://prmbr.com"
NEXTAUTH_URL="https://prmbr.com"
NEXTAUTH_SECRET="your-super-secret-nextauth-secret-32chars"
# Fee Calculation (Optional - defaults to 10x)
FEE_CALCULATION_MULTIPLIER="10.0"
```
### Generating NEXTAUTH_SECRET
@ -269,6 +278,9 @@ FAL_API_KEY="fal-1234567890abcdefghijklmnopqrstuvwxyzABCDEF"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="super-secret-nextauth-development-key-32-chars-long"
# Fee Calculation (Optional - defaults to 10x)
FEE_CALCULATION_MULTIPLIER="10.0"
```
### 🌐 Full Production Configuration
@ -311,6 +323,9 @@ FAL_API_KEY="fal-production-1234567890abcdefghijklmnopqrstuvwxyz"
NEXT_PUBLIC_APP_URL="https://prmbr.com"
NEXTAUTH_URL="https://prmbr.com"
NEXTAUTH_SECRET="super-secret-production-nextauth-key-32-chars-long-secure"
# Fee Calculation (Optional - defaults to 10x)
FEE_CALCULATION_MULTIPLIER="10.0"
```
## 🔒 Security Best Practices

View File

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

View File

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

View File

@ -259,6 +259,8 @@ model SimulatorRun {
output String? // AI响应输出
error String? // 错误信息
status String @default("pending") // "pending", "running", "completed", "failed"
permissions String @default("private") // "private" | "public"
visibility String? // "under_review" | "published" | null
// 运行配置
temperature Float? @default(0.7)
maxTokens Int?
@ -284,6 +286,9 @@ model SimulatorRun {
promptVersion PromptVersion? @relation(fields: [promptVersionId], references: [id], onDelete: SetNull)
model Model @relation(fields: [modelId], references: [id])
// 添加索引优化查询性能
@@index([userId, createdAt(sort: Desc)])
@@index([userId, status, createdAt(sort: Desc)])
@@map("simulator_runs")
}

View File

@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { CheckCircle, XCircle, Eye, Calendar, User as UserIcon } from 'lucide-react'
import { CheckCircle, XCircle, Eye, Calendar, User as UserIcon, FileText, Play, Zap } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
interface ReviewPrompt {
@ -22,9 +22,34 @@ interface ReviewPrompt {
}
}
interface ReviewSimulatorRun {
id: string
name: string
status: string
userInput: string
output?: string | null
visibility: string | null
createdAt: string
user: {
username: string | null
email: string
}
prompt: {
name: string
content: string
}
model: {
name: string
provider: string
outputType?: string
}
}
export default function AdminReviewPage() {
const t = useTranslations('admin')
const [activeTab, setActiveTab] = useState<'prompts' | 'simulators'>('prompts')
const [prompts, setPrompts] = useState<ReviewPrompt[]>([])
const [simulatorRuns, setSimulatorRuns] = useState<ReviewSimulatorRun[]>([])
const [loading, setLoading] = useState(true)
const fetchPendingPrompts = async () => {
@ -36,13 +61,29 @@ export default function AdminReviewPage() {
}
} catch (error) {
console.error('Failed to fetch pending prompts:', error)
} finally {
setLoading(false)
}
}
const fetchPendingSimulators = async () => {
try {
const response = await fetch('/api/admin/simulators/pending')
if (response.ok) {
const data = await response.json()
setSimulatorRuns(data.simulatorRuns)
}
} catch (error) {
console.error('Failed to fetch pending simulator runs:', error)
}
}
const fetchData = async () => {
setLoading(true)
await Promise.all([fetchPendingPrompts(), fetchPendingSimulators()])
setLoading(false)
}
useEffect(() => {
fetchPendingPrompts()
fetchData()
}, [])
const handleApprove = async (promptId: string) => {
@ -71,6 +112,40 @@ export default function AdminReviewPage() {
}
}
const handleApproveSimulator = async (simulatorId: string) => {
try {
const response = await fetch(`/api/admin/simulators/${simulatorId}/approve`, {
method: 'POST'
})
if (response.ok) {
setSimulatorRuns(simulatorRuns.filter(s => s.id !== simulatorId))
}
} catch (error) {
console.error('Failed to approve simulator run:', error)
}
}
const handleRejectSimulator = async (simulatorId: string) => {
try {
const response = await fetch(`/api/admin/simulators/${simulatorId}/reject`, {
method: 'POST'
})
if (response.ok) {
setSimulatorRuns(simulatorRuns.filter(s => s.id !== simulatorId))
}
} catch (error) {
console.error('Failed to reject simulator run:', error)
}
}
const currentItems = activeTab === 'prompts' ? prompts : simulatorRuns
const pendingCount = activeTab === 'prompts'
? prompts.filter(p => !p.visibility || p.visibility === 'under_review').length
: simulatorRuns.filter(s => !s.visibility || s.visibility === 'under_review').length
const publishedCount = activeTab === 'prompts'
? prompts.filter(p => p.visibility === 'published').length
: simulatorRuns.filter(s => s.visibility === 'published').length
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 py-8">
@ -79,10 +154,10 @@ export default function AdminReviewPage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
{t('allPrompts')}
</h1>
<p className="text-muted-foreground">
{t('reviewPromptsDesc')}
</p>
</div>
</div>
@ -92,7 +167,7 @@ export default function AdminReviewPage() {
<div className="flex items-center justify-center min-h-96">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">{t('loadingPrompts')}</p>
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
</div>
@ -106,37 +181,63 @@ export default function AdminReviewPage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
{t('allPrompts')}
</h1>
<p className="text-muted-foreground">
{t('reviewPromptsDesc')}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs sm:text-sm">
{prompts.filter(p => !p.visibility || p.visibility === 'under_review').length} {t('pending')}
{pendingCount}
</Badge>
<Badge variant="outline" className="text-xs sm:text-sm">
{prompts.filter(p => p.visibility === 'published').length} {t('published')}
{publishedCount}
</Badge>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-border mb-6">
<button
onClick={() => setActiveTab('prompts')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'prompts'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<FileText className="h-4 w-4 mr-2 inline" />
</button>
<button
onClick={() => setActiveTab('simulators')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'simulators'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Play className="h-4 w-4 mr-2 inline" />
</button>
</div>
</div>
{/* Content */}
{prompts.length === 0 ? (
{currentItems.length === 0 ? (
<Card className="p-8 lg:p-12 text-center">
<div className="max-w-md mx-auto">
<CheckCircle className="h-12 w-12 lg:h-16 lg:w-16 text-green-500 mx-auto mb-4" />
<h3 className="text-lg lg:text-xl font-semibold text-foreground mb-2">
{t('noPromptsPending')}
</h3>
<p className="text-muted-foreground">
{t('allPromptsReviewed')}
{activeTab === 'prompts' ? '提示词' : '运行结果'}
</p>
</div>
</Card>
) : (
) : activeTab === 'prompts' ? (
<div className="space-y-4 lg:space-y-6">
{prompts.map((prompt) => (
<Card key={prompt.id} className="p-4 lg:p-6 border-border hover:border-primary/20 transition-all duration-200">
@ -162,7 +263,7 @@ export default function AdminReviewPage() {
: ''
}`}
>
{prompt.visibility === 'published' ? t('published') : t('underReview')}
{prompt.visibility === 'published' ? '已发布' : '待审核'}
</Badge>
</div>
</div>
@ -186,7 +287,7 @@ export default function AdminReviewPage() {
<div className="flex items-center gap-2 mb-2">
<Eye className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-muted-foreground">
{t('promptContent')}
</span>
</div>
<div className="text-sm text-foreground max-h-32 lg:max-h-40 overflow-y-auto break-words">
@ -206,7 +307,7 @@ export default function AdminReviewPage() {
size="sm"
>
<CheckCircle className="h-4 w-4 mr-2" />
{t('approve')}
</Button>
)}
<Button
@ -216,7 +317,141 @@ export default function AdminReviewPage() {
className="w-full sm:w-auto"
>
<XCircle className="h-4 w-4 mr-2" />
{t('reject')}
</Button>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="space-y-4 lg:space-y-6">
{simulatorRuns.map((run) => (
<Card key={run.id} className="p-4 lg:p-6 border-border hover:border-primary/20 transition-all duration-200">
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="space-y-1 min-w-0 flex-1">
<h3 className="text-lg lg:text-xl font-semibold text-foreground break-words">
{run.name}
</h3>
<p className="text-sm text-muted-foreground">
{run.model.provider} - {run.model.name}
{run.model.outputType && run.model.outputType !== 'text' && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-0.5 rounded">
{run.model.outputType}
</span>
)}
</p>
</div>
<div className="flex-shrink-0">
<Badge
variant={run.visibility === 'published' ? 'default' : 'outline'}
className={`text-xs sm:text-sm ${
run.visibility === 'published'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: ''
}`}
>
{run.visibility === 'published' ? '已发布' : '待审核'}
</Badge>
</div>
</div>
{/* Metadata */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{run.user.username || run.user.email}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4 flex-shrink-0" />
<span className="whitespace-nowrap">
{formatDistanceToNow(new Date(run.createdAt), { addSuffix: true })}
</span>
</div>
</div>
{/* Prompt and Input Preview */}
<div className="grid gap-4">
<div className="border border-border rounded-lg p-3 lg:p-4 bg-muted/30">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-muted-foreground">
{run.prompt.name}
</span>
</div>
<div className="text-sm text-foreground max-h-20 overflow-y-auto break-words">
{run.prompt.content.length > 200
? `${run.prompt.content.slice(0, 200)}...`
: run.prompt.content
}
</div>
</div>
<div className="border border-border rounded-lg p-3 lg:p-4 bg-muted/30">
<div className="flex items-center gap-2 mb-2">
<Eye className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-muted-foreground">
</span>
</div>
<div className="text-sm text-foreground max-h-20 overflow-y-auto break-words">
{run.userInput.length > 200
? `${run.userInput.slice(0, 200)}...`
: run.userInput
}
</div>
</div>
{run.output && (
<div className="border border-border rounded-lg p-3 lg:p-4 bg-muted/30">
<div className="flex items-center gap-2 mb-2">
<Zap className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-muted-foreground">
</span>
</div>
<div className="text-sm text-foreground max-h-32 lg:max-h-40 overflow-y-auto break-words">
{run.model.outputType === 'image' ? (
<span className="text-muted-foreground"></span>
) : run.output.length > 300 ? (
`${run.output.slice(0, 300)}...`
) : run.output}
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 pt-2 border-t border-border/50">
<Button
onClick={() => window.open(`/simulator/${run.id}`, '_blank')}
variant="outline"
size="sm"
className="w-full sm:w-auto"
>
<Eye className="h-4 w-4 mr-2" />
</Button>
{run.visibility !== 'published' && (
<Button
onClick={() => handleApproveSimulator(run.id)}
className="bg-green-600 hover:bg-green-700 text-white w-full sm:w-auto"
size="sm"
>
<CheckCircle className="h-4 w-4 mr-2" />
</Button>
)}
<Button
onClick={() => handleRejectSimulator(run.id)}
variant="destructive"
size="sm"
className="w-full sm:w-auto"
>
<XCircle className="h-4 w-4 mr-2" />
</Button>
</div>
</div>

View File

@ -0,0 +1,65 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params
const session = await auth.api.getSession({
headers: await headers()
})
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { isAdmin: true }
})
if (!user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Check if simulator run exists and is public
const simulatorRun = await prisma.simulatorRun.findFirst({
where: {
id: params.id,
permissions: 'public'
}
})
if (!simulatorRun) {
return NextResponse.json(
{ error: 'Simulator run not found or not public' },
{ status: 404 }
)
}
// Approve the simulator run
const approvedRun = await prisma.simulatorRun.update({
where: { id: params.id },
data: {
visibility: 'published'
}
})
return NextResponse.json({
success: true,
simulatorRun: approvedRun
})
} catch (error) {
console.error('Error approving simulator run:', error)
return NextResponse.json(
{ error: 'Failed to approve simulator run' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,66 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params
const session = await auth.api.getSession({
headers: await headers()
})
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { isAdmin: true }
})
if (!user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Check if simulator run exists and is public
const simulatorRun = await prisma.simulatorRun.findFirst({
where: {
id: params.id,
permissions: 'public'
}
})
if (!simulatorRun) {
return NextResponse.json(
{ error: 'Simulator run not found or not public' },
{ status: 404 }
)
}
// Reject the simulator run by setting it back to private
const rejectedRun = await prisma.simulatorRun.update({
where: { id: params.id },
data: {
permissions: 'private',
visibility: null
}
})
return NextResponse.json({
success: true,
simulatorRun: rejectedRun
})
} catch (error) {
console.error('Error rejecting simulator run:', error)
return NextResponse.json(
{ error: 'Failed to reject simulator run' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
export async function GET() {
try {
const session = await auth.api.getSession({
headers: await headers()
})
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { isAdmin: true }
})
if (!user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get simulator runs that are public (including pending and published ones for review)
const simulatorRuns = await prisma.simulatorRun.findMany({
where: {
permissions: 'public',
OR: [
{ visibility: null },
{ visibility: 'under_review' },
{ visibility: 'published' }
]
},
include: {
user: {
select: {
username: true,
email: true
}
},
prompt: {
select: {
name: true,
content: true
}
},
model: {
select: {
name: true,
provider: true,
outputType: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json({ simulatorRuns })
} catch (error) {
console.error('Error fetching pending simulator runs:', error)
return NextResponse.json(
{ error: 'Failed to fetch simulator runs' },
{ status: 500 }
)
}
}

View File

@ -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 }
);
}
}

View File

@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const search = searchParams.get('search') || ''
const tag = searchParams.get('tag') || ''
const type = searchParams.get('type') || 'prompts' // 'prompts' | 'simulators' | 'all'
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '12')
const sortBy = searchParams.get('sortBy') || 'createdAt'
@ -15,97 +16,203 @@ export async function GET(request: NextRequest) {
const skip = (page - 1) * limit
// 构建where条件
const where: Record<string, unknown> = {
permissions: 'public',
visibility: 'published',
}
// 搜索条件:根据名称和描述匹配
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
// 标签过滤
if (tag) {
where.tags = {
some: {
name: tag
}
if (type === 'prompts') {
// 获取提示词数据
const where: Record<string, unknown> = {
permissions: 'public',
visibility: 'published',
}
}
// 排序条件
const orderBy: Record<string, string> = {}
if (sortBy === 'name') {
orderBy.name = sortOrder
} else {
orderBy.createdAt = sortOrder
}
// 搜索条件:根据名称和描述匹配
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
// 获取总数和数据
const [total, prompts] = await Promise.all([
prisma.prompt.count({ where }),
prisma.prompt.findMany({
where,
skip,
take: limit,
orderBy,
include: {
user: {
select: {
id: true,
username: true,
image: true,
}
},
tags: {
select: {
id: true,
name: true,
color: true,
}
},
versions: {
orderBy: {
version: 'desc'
},
take: 1,
select: {
content: true,
version: true,
createdAt: true,
}
},
_count: {
select: {
versions: true,
}
// 标签过滤
if (tag) {
where.tags = {
some: {
name: tag
}
}
})
])
const totalPages = Math.ceil(total / limit)
return NextResponse.json({
prompts,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
}
})
// 排序条件
const orderBy: Record<string, string> = {}
if (sortBy === 'name') {
orderBy.name = sortOrder
} else {
orderBy.createdAt = sortOrder
}
// 获取总数和数据
const [total, prompts] = await Promise.all([
prisma.prompt.count({ where }),
prisma.prompt.findMany({
where,
skip,
take: limit,
orderBy,
include: {
user: {
select: {
id: true,
username: true,
image: true,
}
},
tags: {
select: {
id: true,
name: true,
color: true,
}
},
versions: {
orderBy: {
version: 'desc'
},
take: 1,
select: {
content: true,
version: true,
createdAt: true,
}
},
stats: {
select: {
viewCount: true,
likeCount: true,
rating: true,
ratingCount: true,
}
},
_count: {
select: {
versions: true,
}
}
}
})
])
const totalPages = Math.ceil(total / limit)
return NextResponse.json({
prompts,
type: 'prompts',
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
}
})
} else if (type === 'simulators') {
// 获取运行结果数据
const where: Record<string, unknown> = {
permissions: 'public',
visibility: 'published',
}
// 搜索条件
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ userInput: { contains: search, mode: 'insensitive' } },
]
}
// 排序条件
const orderBy: Record<string, string> = {}
if (sortBy === 'name') {
orderBy.name = sortOrder
} else {
orderBy.createdAt = sortOrder
}
// 获取总数和数据
const [total, simulatorRuns] = await Promise.all([
prisma.simulatorRun.count({ where }),
prisma.simulatorRun.findMany({
where,
skip,
take: limit,
orderBy,
include: {
user: {
select: {
id: true,
username: true,
image: true,
}
},
prompt: {
select: {
id: true,
name: true,
content: true,
tags: {
select: {
id: true,
name: true,
color: true,
}
}
}
},
model: {
select: {
name: true,
provider: true,
outputType: true,
description: true,
}
}
}
})
])
const totalPages = Math.ceil(total / limit)
return NextResponse.json({
simulatorRuns,
type: 'simulators',
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
}
})
} else {
// 返回混合内容 (暂时返回空,可以后续实现)
return NextResponse.json({
prompts: [],
simulatorRuns: [],
type: 'all',
pagination: {
page,
limit,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
}
})
}
} catch (error) {
console.error('Error fetching plaza prompts:', error)
console.error('Error fetching plaza content:', error)
return NextResponse.json(
{ error: 'Failed to fetch prompts' },
{ error: 'Failed to fetch content' },
{ status: 500 }
)
}

View File

@ -66,9 +66,9 @@ const IMAGE_MODEL_ADAPTERS: Record<string, ModelAdapter> = {
}
}
// 如果有图片数据,返回图片数据,否则返回文本内容
// 如果有图片数据,不返回到 content 中,只存储到 imageData
return {
content: imageData || content || 'No image data found in response',
content: imageData ? '' : (content || 'No image data found in response'),
outputType: 'image',
imageData: imageData
}
@ -130,7 +130,7 @@ export async function POST(
// Check user's credit balance before execution
const userBalance = await getUserBalance(user.id);
const costMultiplier = (run.user.subscriptionPlan as { costMultiplier?: number })?.costMultiplier || 1.0;
const estimatedCost = calculateCost(0, 100, run.model, costMultiplier); // Rough estimate
const estimatedCost = calculateCost(50, 100, run.model, costMultiplier); // Rough estimate
if (userBalance < estimatedCost) {
return NextResponse.json(

View File

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

View File

@ -0,0 +1,91 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params
const session = await auth.api.getSession({
headers: await headers()
})
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { permissions } = await request.json()
if (!permissions || !['private', 'public'].includes(permissions)) {
return NextResponse.json(
{ error: 'Invalid permissions value' },
{ status: 400 }
)
}
// 检查 simulator run 是否存在且属于当前用户
const run = await prisma.simulatorRun.findFirst({
where: {
id: params.id,
userId: session.user.id
}
})
if (!run) {
return NextResponse.json(
{ error: 'Simulator run not found' },
{ status: 404 }
)
}
// 只有已完成的 run 才能共享
if (run.status !== 'completed') {
return NextResponse.json(
{ error: 'Only completed simulator runs can be shared' },
{ status: 400 }
)
}
// 更新权限设置
const updatedRun = await prisma.simulatorRun.update({
where: { id: params.id },
data: {
permissions,
// 如果设为 public重置 visibility 为等待审核状态
visibility: permissions === 'public' ? null : null
},
include: {
prompt: {
select: {
id: true,
name: true,
content: true
}
},
model: {
select: {
id: true,
name: true,
provider: true,
modelId: true,
outputType: true,
description: true,
maxTokens: true
}
}
}
})
return NextResponse.json(updatedRun)
} catch (error) {
console.error('Error toggling simulator run share status:', error)
return NextResponse.json(
{ error: 'Failed to update share status' },
{ status: 500 }
)
}
}

View File

@ -95,11 +95,9 @@ export async function GET(request: NextRequest) {
output: true,
error: true,
createdAt: true,
completedAt: true,
inputTokens: true,
outputTokens: true,
totalCost: true,
duration: true,
prompt: {
select: { id: true, name: true }
},

View File

@ -67,14 +67,14 @@ export async function POST() {
}
if (isNewUser) {
// 为新用户添加系统赠送的5USD信用额度1个月后过期
// 为新用户添加系统赠送的2USD信用额度1个月后过期
const expiresAt = new Date()
expiresAt.setMonth(expiresAt.getMonth() + 1)
try {
await addCredit(
user.id,
5.0,
2.0,
'system_gift',
'系统赠送 - 新用户礼包',
expiresAt

View File

@ -98,14 +98,14 @@ export default async function RootLayout({
></script>
{/* Google Analytics */}
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NML19W8SRD"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-4ZK9RFLTEM"></script>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-NML19W8SRD');
gtag('config', 'G-4ZK9RFLTEM');
`,
}}
/>

View File

@ -66,13 +66,11 @@ export default function ProfilePage() {
username: '',
email: '',
bio: '',
currentPassword: '',
newPassword: '',
confirmPassword: '',
versionLimit: 3
})
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
})
@ -105,7 +103,6 @@ export default function ProfilePage() {
username: profileData.name || '', // 直接使用name字段
email: profileData.email,
bio: profileData.bio || '',
currentPassword: '',
newPassword: '',
confirmPassword: '',
versionLimit: profileData.versionLimit
@ -193,9 +190,40 @@ export default function ProfilePage() {
setSaveStatus({ type: null, message: '' })
try {
// TODO: 实现Better Auth密码更新
// Better Auth的密码更新需要通过特殊的API端点
setSaveStatus({ type: 'error', message: 'Password update is not yet implemented for Better Auth' })
// 使用自定义API来设置密码只需要新密码
const response = await fetch('/api/auth/set-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
newPassword: formData.newPassword
})
})
if (!response.ok) {
let errorMessage = 'Failed to set password'
try {
const errorData = await response.json()
errorMessage = errorData.error || errorMessage
} catch {
// 如果响应不是JSON格式使用状态码信息
errorMessage = `HTTP ${response.status}: ${response.statusText}`
}
throw new Error(errorMessage)
}
// 尝试解析成功响应
try {
const result = await response.json()
console.log('Password set successfully:', result)
} catch {
// 即使解析失败,如果状态码是成功的,仍然认为操作成功
console.log('Password set successfully (no JSON response)')
}
setSaveStatus({ type: 'success', message: t('passwordUpdatedSuccessfully') })
setFormData({ ...formData, newPassword: '', confirmPassword: '' })
} catch (error: unknown) {
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword') })

View File

@ -29,7 +29,9 @@ import {
DollarSign,
Edit,
Save,
X
X,
Share2,
Globe
} from 'lucide-react'
import Link from 'next/link'
import { formatDistanceToNow } from 'date-fns'
@ -48,6 +50,8 @@ interface SimulatorRun {
output?: string
error?: string
outputType?: string
permissions: string
visibility?: string | null
createdAt: string
completedAt?: string
temperature?: number
@ -108,6 +112,7 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null)
const [isLoadingImage, setIsLoadingImage] = useState(false)
const [imageLoadError, setImageLoadError] = useState(false)
const [isTogglingShare, setIsTogglingShare] = useState(false)
const [editForm, setEditForm] = useState({
name: '',
userInput: '',
@ -386,6 +391,35 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
}
}
const handleToggleShare = async () => {
if (!run) return
setIsTogglingShare(true)
try {
const newPermissions = run.permissions === 'private' ? 'public' : 'private'
const response = await fetch(`/api/simulator/${run.id}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
permissions: newPermissions,
}),
})
if (response.ok) {
const updatedRun = await response.json()
setRun(updatedRun)
} else {
console.error('Error toggling share status:', await response.text())
}
} catch (error) {
console.error('Error toggling share status:', error)
} finally {
setIsTogglingShare(false)
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
@ -491,6 +525,18 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
<span>{t(`status.${run.status}`)}</span>
</div>
</Badge>
{/* 分享状态显示 */}
{run.permissions === 'public' && (
<Badge variant="secondary" className="text-xs">
{run.visibility === 'published' ? (
<span className="text-green-600 dark:text-green-400"></span>
) : (
<span className="text-orange-600 dark:text-orange-400"></span>
)}
</Badge>
)}
<span>{run.model.provider} {run.model.name}</span>
<span>
{formatDistanceToNow(new Date(run.createdAt), {
@ -502,6 +548,32 @@ export default function SimulatorRunPage({ params }: { params: Promise<{ id: str
</div>
<div className="flex items-center space-x-2">
{/* 共享按钮 - 只有已完成的 run 才能共享 */}
{run.status === 'completed' && (
<Button
onClick={handleToggleShare}
disabled={isTogglingShare}
variant={run.permissions === 'public' ? 'default' : 'outline'}
>
{isTogglingShare ? (
<>
<LoadingSpinner size="sm" />
<span className="ml-2">{t('updating')}</span>
</>
) : run.permissions === 'public' ? (
<>
<Globe className="h-4 w-4 mr-2" />
{t('shared')}
</>
) : (
<>
<Share2 className="h-4 w-4 mr-2" />
{t('shareRun')}
</>
)}
</Button>
)}
<Button
onClick={handleDuplicateRun}
disabled={isDuplicating}

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { useAuthUser } from '@/hooks/useAuthUser'
import { Header } from '@/components/layout/Header'
@ -160,7 +160,7 @@ export default function SimulatorPage() {
}
}
const getRunStats = () => {
const stats = useMemo(() => {
const total = runs.length
const completed = runs.filter(run => run.status === 'completed').length
const running = runs.filter(run => run.status === 'running').length
@ -173,7 +173,7 @@ export default function SimulatorPage() {
const totalCost = runs.reduce((sum, run) => sum + (run.totalCost || 0), 0)
return { total, completed, running, failed, totalTokens, totalCost }
}
}, [runs])
if (authLoading) {
return (
@ -190,7 +190,6 @@ export default function SimulatorPage() {
return null
}
const stats = getRunStats()
return (
<div className="min-h-screen">

View File

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

View File

@ -0,0 +1,235 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Play,
User as UserIcon,
Calendar,
Eye,
FileText,
Zap,
ExternalLink,
Copy
} from 'lucide-react'
import Link from 'next/link'
import { formatDistanceToNow } from 'date-fns'
import { zhCN, enUS } from 'date-fns/locale'
import { useLocale } from 'next-intl'
import { Base64Image } from '@/components/ui/Base64Image'
interface SimulatorRun {
id: string
name: string
status: string
userInput: string
output?: string | null
createdAt: string
user: {
id: string
username: string | null
image: string | null
}
prompt: {
id: string
name: string
content: string
tags: Array<{
id: string
name: string
color: string
}>
}
model: {
name: string
provider: string
outputType?: string
description?: string
}
}
interface SimulatorCardProps {
simulatorRun: SimulatorRun
onViewIncrement?: (id: string) => void
}
export function SimulatorCard({ simulatorRun, onViewIncrement }: SimulatorCardProps) {
const t = useTranslations('plaza')
const locale = useLocale()
const [imageError, setImageError] = useState(false)
const handleViewClick = () => {
if (onViewIncrement) {
onViewIncrement(simulatorRun.id)
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
} catch (error) {
console.error('Failed to copy to clipboard:', error)
}
}
// 检查是否是图片输出
const isImageOutput = simulatorRun.model.outputType === 'image'
const hasImageUrl = simulatorRun.output && (
simulatorRun.output.includes('http') &&
simulatorRun.output.match(/\.(png|jpg|jpeg|gif|webp)/i)
)
return (
<Card className="group hover:shadow-lg transition-all duration-300 border-border hover:border-primary/30 overflow-hidden">
<div className="p-6 space-y-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1 min-w-0 flex-1">
<h3 className="text-lg font-semibold text-foreground group-hover:text-primary transition-colors line-clamp-2">
{simulatorRun.name}
</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{simulatorRun.model.provider}</span>
<span>-</span>
<span>{simulatorRun.model.name}</span>
{simulatorRun.model.outputType && simulatorRun.model.outputType !== 'text' && (
<Badge variant="secondary" className="text-xs">
{simulatorRun.model.outputType}
</Badge>
)}
</div>
</div>
</div>
{/* Tags from prompt */}
{simulatorRun.prompt.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{simulatorRun.prompt.tags.slice(0, 3).map((tag) => (
<Badge
key={tag.id}
variant="secondary"
className="text-xs px-2 py-0.5"
style={{
backgroundColor: `${tag.color}15`,
borderColor: `${tag.color}30`,
color: tag.color
}}
>
{tag.name}
</Badge>
))}
{simulatorRun.prompt.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{simulatorRun.prompt.tags.length - 3}
</Badge>
)}
</div>
)}
</div>
{/* Prompt Info */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileText className="h-4 w-4" />
<span>{simulatorRun.prompt.name}</span>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-sm">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="text-foreground line-clamp-2">
{simulatorRun.userInput}
</div>
</div>
</div>
{/* Output Preview */}
{simulatorRun.output && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Zap className="h-4 w-4" />
<span></span>
</div>
{isImageOutput && hasImageUrl ? (
<div className="relative bg-muted/30 rounded-lg p-3 overflow-hidden">
{!imageError ? (
<div className="relative group cursor-pointer">
<Base64Image
src={simulatorRun.output}
alt="Generated image"
className="w-full max-h-48 object-cover rounded-md"
onError={() => setImageError(true)}
onClick={() => window.open(simulatorRun.output!, '_blank')}
/>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-md flex items-center justify-center">
<ExternalLink className="h-6 w-6 text-white" />
</div>
</div>
) : (
<div className="bg-muted rounded-md p-6 text-center">
<div className="text-sm text-muted-foreground">
</div>
</div>
)}
</div>
) : (
<div className="bg-muted/30 rounded-lg p-3">
<div className="text-sm text-foreground line-clamp-3">
{isImageOutput ?
"🎨 图片已生成,点击查看详情" :
simulatorRun.output
}
</div>
</div>
)}
</div>
)}
{/* Meta Info */}
<div className="flex items-center justify-between text-sm text-muted-foreground pt-2 border-t border-border/50">
<div className="flex items-center gap-2">
<UserIcon className="h-4 w-4" />
<span className="truncate max-w-24">
{simulatorRun.user.username || simulatorRun.user.id.slice(0, 8)}
</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span className="whitespace-nowrap">
{formatDistanceToNow(new Date(simulatorRun.createdAt), {
addSuffix: true,
locale: locale === 'zh' ? zhCN : enUS
})}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Link href={`/simulator/${simulatorRun.id}`} className="flex-1">
<Button
variant="default"
size="sm"
className="w-full"
onClick={handleViewClick}
>
<Eye className="h-4 w-4 mr-2" />
</Button>
</Link>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(simulatorRun.userInput)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
)
}

View File

@ -11,5 +11,6 @@ export const {
signUp,
signOut,
useSession,
getSession
getSession,
changePassword
} = authClient

View File

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